Merge branch 'main' into iiif-mirador

This commit is contained in:
Michael Spalti
2021-05-16 16:30:34 -07:00
90 changed files with 1673 additions and 609 deletions

View File

@@ -179,17 +179,18 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
link: '/processes/new'
} as LinkMenuItemModel,
},
{
id: 'new_item_version',
parentID: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_item_version',
link: ''
} as LinkMenuItemModel,
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'new_item_version',
// parentID: 'new',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.new_item_version',
// link: ''
// } as LinkMenuItemModel,
// },
/* Edit */
{
@@ -243,47 +244,35 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
} as OnClickMenuItemModel,
},
/* Curation tasks */
{
id: 'curation_tasks',
active: false,
visible: isCollectionAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.curation_task',
link: ''
} as LinkMenuItemModel,
icon: 'filter',
index: 7
},
/* Statistics */
{
id: 'statistics_task',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics_task',
link: ''
} as LinkMenuItemModel,
icon: 'chart-bar',
index: 8
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'statistics_task',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.statistics_task',
// link: ''
// } as LinkMenuItemModel,
// icon: 'chart-bar',
// index: 8
// },
/* Control Panel */
{
id: 'control_panel',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.control_panel',
link: ''
} as LinkMenuItemModel,
icon: 'cogs',
index: 9
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'control_panel',
// active: false,
// visible: isSiteAdmin,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.control_panel',
// link: ''
// } as LinkMenuItemModel,
// icon: 'cogs',
// index: 9
// },
/* Processes */
{
@@ -324,42 +313,45 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
index: 3,
shouldPersistOnRouteChange: true
},
{
id: 'export_community',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_community',
link: ''
} as LinkMenuItemModel,
shouldPersistOnRouteChange: true
},
{
id: 'export_collection',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_collection',
link: ''
} as LinkMenuItemModel,
shouldPersistOnRouteChange: true
},
{
id: 'export_item',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_item',
link: ''
} as LinkMenuItemModel,
shouldPersistOnRouteChange: true
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'export_community',
// parentID: 'export',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.export_community',
// link: ''
// } as LinkMenuItemModel,
// shouldPersistOnRouteChange: true
// },
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'export_collection',
// parentID: 'export',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.export_collection',
// link: ''
// } as LinkMenuItemModel,
// shouldPersistOnRouteChange: true
// },
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'export_item',
// parentID: 'export',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.export_item',
// link: ''
// } as LinkMenuItemModel,
// shouldPersistOnRouteChange: true
// },
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection));
@@ -406,17 +398,18 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
icon: 'file-import',
index: 2
},
{
id: 'import_batch',
parentID: 'import',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.import_batch',
link: ''
} as LinkMenuItemModel,
}
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'import_batch',
// parentID: 'import',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.import_batch',
// link: ''
// } as LinkMenuItemModel,
// }
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
@@ -563,17 +556,18 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
link: '/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,
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'access_control_authorizations',
// parentID: 'access_control',
// active: false,
// visible: authorized,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.access_control_authorizations',
// link: ''
// } as LinkMenuItemModel,
// },
{
id: 'access_control',
active: false,

View File

@@ -1,4 +1,6 @@
import { hasNoValue } from '../../shared/empty.util';
import { InjectionToken } from '@angular/core';
import { GenericConstructor } from '../../core/shared/generic-constructor';
export enum BrowseByType {
Title = 'title',
@@ -8,6 +10,11 @@ export enum BrowseByType {
export const DEFAULT_BROWSE_BY_TYPE = BrowseByType.Metadata;
export const BROWSE_BY_COMPONENT_FACTORY = new InjectionToken<(browseByType) => GenericConstructor<any>>('getComponentByBrowseByType', {
providedIn: 'root',
factory: () => getComponentByBrowseByType
});
const map = new Map();
/**

View File

@@ -2,12 +2,11 @@ import { BrowseBySwitcherComponent } from './browse-by-switcher.component';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import * as decorator from './browse-by-decorator';
import { BehaviorSubject } from 'rxjs';
import { environment } from '../../../environments/environment';
import createSpy = jasmine.createSpy;
import { BROWSE_BY_COMPONENT_FACTORY } from './browse-by-decorator';
xdescribe('BrowseBySwitcherComponent', () => {
describe('BrowseBySwitcherComponent', () => {
let comp: BrowseBySwitcherComponent;
let fixture: ComponentFixture<BrowseBySwitcherComponent>;
@@ -23,7 +22,8 @@ xdescribe('BrowseBySwitcherComponent', () => {
TestBed.configureTestingModule({
declarations: [BrowseBySwitcherComponent],
providers: [
{ provide: ActivatedRoute, useValue: activatedRouteStub }
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{ provide: BROWSE_BY_COMPONENT_FACTORY, useValue: jasmine.createSpy('getComponentByBrowseByType').and.returnValue(null) }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
@@ -32,7 +32,6 @@ xdescribe('BrowseBySwitcherComponent', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(BrowseBySwitcherComponent);
comp = fixture.componentInstance;
spyOnProperty(decorator, 'getComponentByBrowseByType').and.returnValue(createSpy('getComponentByItemType'));
}));
types.forEach((type) => {
@@ -43,7 +42,7 @@ xdescribe('BrowseBySwitcherComponent', () => {
});
it(`should call getComponentByBrowseByType with type "${type.type}"`, () => {
expect(decorator.getComponentByBrowseByType).toHaveBeenCalledWith(type.type);
expect((comp as any).getComponentByBrowseByType).toHaveBeenCalledWith(type.type);
});
});
});

View File

@@ -1,10 +1,11 @@
import { Component, OnInit } from '@angular/core';
import { Component, Inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { BrowseByTypeConfig } from '../../../config/browse-by-type-config.interface';
import { map } from 'rxjs/operators';
import { getComponentByBrowseByType } from './browse-by-decorator';
import { BROWSE_BY_COMPONENT_FACTORY } from './browse-by-decorator';
import { environment } from '../../../environments/environment';
import { GenericConstructor } from '../../core/shared/generic-constructor';
@Component({
selector: 'ds-browse-by-switcher',
@@ -20,7 +21,8 @@ export class BrowseBySwitcherComponent implements OnInit {
*/
browseByComponent: Observable<any>;
public constructor(protected route: ActivatedRoute) {
public constructor(protected route: ActivatedRoute,
@Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType) => GenericConstructor<any>) {
}
/**
@@ -32,7 +34,7 @@ export class BrowseBySwitcherComponent implements OnInit {
const id = params.id;
return environment.browseBy.types.find((config: BrowseByTypeConfig) => config.id === id);
}),
map((config: BrowseByTypeConfig) => getComponentByBrowseByType(config.type))
map((config: BrowseByTypeConfig) => this.getComponentByBrowseByType(config.type))
);
}

View File

@@ -4,7 +4,7 @@ import { Collection } from '../core/shared/collection.model';
import { CollectionPageResolver } from './collection-page.resolver';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { Observable, of as observableOf } from 'rxjs';
import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { DsoPageSingleFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { AuthService } from '../core/auth/auth.service';
@@ -14,7 +14,7 @@ import { AuthService } from '../core/auth/auth.service';
/**
* Guard for preventing unauthorized access to certain {@link Collection} pages requiring administrator rights
*/
export class CollectionPageAdministratorGuard extends DsoPageFeatureGuard<Collection> {
export class CollectionPageAdministratorGuard extends DsoPageSingleFeatureGuard<Collection> {
constructor(protected resolver: CollectionPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,

View File

@@ -4,7 +4,7 @@ import { Community } from '../core/shared/community.model';
import { CommunityPageResolver } from './community-page.resolver';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { Observable, of as observableOf } from 'rxjs';
import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { DsoPageSingleFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { AuthService } from '../core/auth/auth.service';
@@ -14,7 +14,7 @@ import { AuthService } from '../core/auth/auth.service';
/**
* Guard for preventing unauthorized access to certain {@link Community} pages requiring administrator rights
*/
export class CommunityPageAdministratorGuard extends DsoPageFeatureGuard<Community> {
export class CommunityPageAdministratorGuard extends DsoPageSingleFeatureGuard<Community> {
constructor(protected resolver: CommunityPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,

View File

@@ -31,8 +31,13 @@ import {
} from './edit-item-page.routing-paths';
import { ItemPageReinstateGuard } from './item-page-reinstate.guard';
import { ItemPageWithdrawGuard } from './item-page-withdraw.guard';
import { ItemPageEditMetadataGuard } from '../item-page-edit-metadata.guard';
import { ItemPageMetadataGuard } from './item-page-metadata.guard';
import { ItemPageAdministratorGuard } from '../item-page-administrator.guard';
import { ItemPageStatusGuard } from './item-page-status.guard';
import { ItemPageBitstreamsGuard } from './item-page-bitstreams.guard';
import { ItemPageRelationshipsGuard } from './item-page-relationships.guard';
import { ItemPageVersionHistoryGuard } from './item-page-version-history.guard';
import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.guard';
/**
* Routing module that handles the routing for the Edit Item page administrator functionality
@@ -60,25 +65,25 @@ import { ItemPageAdministratorGuard } from '../item-page-administrator.guard';
path: 'status',
component: ItemStatusComponent,
data: { title: 'item.edit.tabs.status.title', showBreadcrumbs: true },
canActivate: [ItemPageAdministratorGuard]
canActivate: [ItemPageStatusGuard]
},
{
path: 'bitstreams',
component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.bitstreams.title', showBreadcrumbs: true },
canActivate: [ItemPageAdministratorGuard]
canActivate: [ItemPageBitstreamsGuard]
},
{
path: 'metadata',
component: ItemMetadataComponent,
data: { title: 'item.edit.tabs.metadata.title', showBreadcrumbs: true },
canActivate: [ItemPageEditMetadataGuard]
canActivate: [ItemPageMetadataGuard]
},
{
path: 'relationships',
component: ItemRelationshipsComponent,
data: { title: 'item.edit.tabs.relationships.title', showBreadcrumbs: true },
canActivate: [ItemPageEditMetadataGuard]
canActivate: [ItemPageRelationshipsGuard]
},
/* TODO - uncomment & fix when view page exists
{
@@ -96,13 +101,13 @@ import { ItemPageAdministratorGuard } from '../item-page-administrator.guard';
path: 'versionhistory',
component: ItemVersionHistoryComponent,
data: { title: 'item.edit.tabs.versionhistory.title', showBreadcrumbs: true },
canActivate: [ItemPageAdministratorGuard]
canActivate: [ItemPageVersionHistoryGuard]
},
{
path: 'mapper',
component: ItemCollectionMapperComponent,
data: { title: 'item.edit.tabs.item-mapper.title', showBreadcrumbs: true },
canActivate: [ItemPageAdministratorGuard]
canActivate: [ItemPageCollectionMapperGuard]
}
]
},
@@ -175,7 +180,12 @@ import { ItemPageAdministratorGuard } from '../item-page-administrator.guard';
ItemPageReinstateGuard,
ItemPageWithdrawGuard,
ItemPageAdministratorGuard,
ItemPageEditMetadataGuard,
ItemPageMetadataGuard,
ItemPageStatusGuard,
ItemPageBitstreamsGuard,
ItemPageRelationshipsGuard,
ItemPageVersionHistoryGuard,
ItemPageCollectionMapperGuard,
]
})
export class EditItemPageRoutingModule {

View File

@@ -3,13 +3,15 @@
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.label' | translate}}
</span>
</div>
<div *ngIf="!operation.disabled" class="col-9 float-left action-button">
<a class="btn btn-outline-primary" [routerLink]="operation.operationUrl">
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}}
</a>
</div>
<div *ngIf="operation.disabled" class="col-9 float-left action-button">
<span class="btn btn-danger">
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}}
<div class="col-9 float-left action-button">
<span *ngIf="operation.authorized">
<button class="btn btn-outline-primary" [disabled]="operation.disabled" [routerLink]="operation.operationUrl">
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}}
</button>
</span>
<span *ngIf="!operation.authorized" [ngbTooltip]="'item.edit.tabs.status.buttons.unauthorized' | translate">
<button class="btn btn-outline-primary" [disabled]="true">
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}}
</button>
</span>
</div>

View File

@@ -28,19 +28,19 @@ describe('ItemOperationComponent', () => {
});
it('should render operation row', () => {
const span = fixture.debugElement.query(By.css('span')).nativeElement;
const span = fixture.debugElement.query(By.css('.action-label span')).nativeElement;
expect(span.textContent).toContain('item.edit.tabs.status.buttons.key1.label');
const link = fixture.debugElement.query(By.css('a')).nativeElement;
expect(link.href).toContain('url1');
expect(link.textContent).toContain('item.edit.tabs.status.buttons.key1.button');
const button = fixture.debugElement.query(By.css('button')).nativeElement;
expect(button.textContent).toContain('item.edit.tabs.status.buttons.key1.button');
});
it('should render disabled operation row', () => {
itemOperation.setDisabled(true);
fixture.detectChanges();
const span = fixture.debugElement.query(By.css('span')).nativeElement;
const span = fixture.debugElement.query(By.css('.action-label span')).nativeElement;
expect(span.textContent).toContain('item.edit.tabs.status.buttons.key1.label');
const span2 = fixture.debugElement.query(By.css('span.btn-danger')).nativeElement;
expect(span2.textContent).toContain('item.edit.tabs.status.buttons.key1.button');
const button = fixture.debugElement.query(By.css('button')).nativeElement;
expect(button.disabled).toBeTrue();
expect(button.textContent).toContain('item.edit.tabs.status.buttons.key1.button');
});
});

View File

@@ -1,3 +1,5 @@
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
/**
* Represents an item operation used on the edit item page with a key, an operation URL to which will be navigated
* when performing the action and an option to disable the operation.
@@ -7,11 +9,15 @@ export class ItemOperation {
operationKey: string;
operationUrl: string;
disabled: boolean;
authorized: boolean;
featureID: FeatureID;
constructor(operationKey: string, operationUrl: string) {
constructor(operationKey: string, operationUrl: string, featureID?: FeatureID, disabled = false, authorized = true) {
this.operationKey = operationKey;
this.operationUrl = operationUrl;
this.setDisabled(false);
this.featureID = featureID;
this.authorized = authorized;
this.setDisabled(disabled);
}
/**

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { Item } from '../../core/shared/item.model';
import { ItemPageResolver } from '../item-page.resolver';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { AuthService } from '../../core/auth/auth.service';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring manage bitstreams rights
*/
export class ItemPageBitstreamsGuard extends DsoPageSingleFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**
* Check manage bitstreams authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanManageBitstreamBundles);
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from '../item-page.resolver';
import { Item } from '../../core/shared/item.model';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { AuthService } from '../../core/auth/auth.service';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring manage mappings rights
*/
export class ItemPageCollectionMapperGuard extends DsoPageSingleFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**
* Check manage mappings authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanManageMappings);
}
}

View File

@@ -1,12 +1,12 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from './item-page.resolver';
import { Item } from '../core/shared/item.model';
import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from '../item-page.resolver';
import { Item } from '../../core/shared/item.model';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { AuthService } from '../core/auth/auth.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { AuthService } from '../../core/auth/auth.service';
@Injectable({
providedIn: 'root'
@@ -14,7 +14,7 @@ import { AuthService } from '../core/auth/auth.service';
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring edit metadata rights
*/
export class ItemPageEditMetadataGuard extends DsoPageFeatureGuard<Item> {
export class ItemPageMetadataGuard extends DsoPageSingleFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { DsoPageFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { Item } from '../../core/shared/item.model';
import { ItemPageResolver } from '../item-page.resolver';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
@@ -14,7 +14,7 @@ import { AuthService } from '../../core/auth/auth.service';
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring reinstate rights
*/
export class ItemPageReinstateGuard extends DsoPageFeatureGuard<Item> {
export class ItemPageReinstateGuard extends DsoPageSingleFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from '../item-page.resolver';
import { Item } from '../../core/shared/item.model';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { AuthService } from '../../core/auth/auth.service';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring manage relationships rights
*/
export class ItemPageRelationshipsGuard extends DsoPageSingleFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**
* Check manage relationships authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanManageRelationships);
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { Item } from '../../core/shared/item.model';
import { ItemPageResolver } from '../item-page.resolver';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { AuthService } from '../../core/auth/auth.service';
import { DsoPageSomeFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring any of the rights required for
* the status page
*/
export class ItemPageStatusGuard extends DsoPageSomeFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**
* Check authorization rights
*/
getFeatureIDs(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return observableOf([FeatureID.CanManageMappings, FeatureID.WithdrawItem, FeatureID.ReinstateItem, FeatureID.CanManagePolicies, FeatureID.CanMakePrivate, FeatureID.CanDelete, FeatureID.CanMove]);
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from '../item-page.resolver';
import { Item } from '../../core/shared/item.model';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { AuthService } from '../../core/auth/auth.service';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring manage versions rights
*/
export class ItemPageVersionHistoryGuard extends DsoPageSingleFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**
* Check manage versions authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanManageVersions);
}
}

View File

@@ -1,4 +1,4 @@
import { DsoPageFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { Item } from '../../core/shared/item.model';
import { Injectable } from '@angular/core';
import { ItemPageResolver } from '../item-page.resolver';
@@ -14,7 +14,7 @@ import { AuthService } from '../../core/auth/auth.service';
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring withdraw rights
*/
export class ItemPageWithdrawGuard extends DsoPageFeatureGuard<Item> {
export class ItemPageWithdrawGuard extends DsoPageSingleFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,

View File

@@ -18,7 +18,7 @@ import { RelationshipType } from '../../../../core/shared/item-relationships/rel
import {
getAllSucceededRemoteData,
getRemoteDataPayload,
getFirstSucceededRemoteData,
getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload,
} from '../../../../core/shared/operators';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
import { DsDynamicLookupRelationModalComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component';
@@ -29,6 +29,7 @@ import { SearchResult } from '../../../../shared/search/search-result.model';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { Collection } from '../../../../core/shared/collection.model';
@Component({
selector: 'ds-edit-relationship-list',
@@ -146,6 +147,11 @@ export class EditRelationshipListComponent implements OnInit {
modalComp.repeatable = true;
modalComp.listId = this.listId;
modalComp.item = this.item;
this.item.owningCollection.pipe(
getFirstSucceededRemoteDataPayload()
).subscribe((collection: Collection) => {
modalComp.collection = collection;
});
modalComp.select = (...selectableObjects: SearchResult<Item>[]) => {
selectableObjects.forEach((searchResult) => {
const relatedItem: Item = searchResult.indexableObject;

View File

@@ -3,8 +3,8 @@ import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute } from '@angular/router';
import { ItemOperation } from '../item-operation/itemOperation.model';
import { distinctUntilChanged, first, map } from 'rxjs/operators';
import { BehaviorSubject, Observable } from 'rxjs';
import { distinctUntilChanged, first, map, mergeMap, toArray } from 'rxjs/operators';
import { BehaviorSubject, Observable, from as observableFrom } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
@@ -78,42 +78,36 @@ export class ItemStatusComponent implements OnInit {
The value is supposed to be a href for the button
*/
const operations = [];
operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations'));
operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper'));
operations.push(undefined);
// Store the index of the "withdraw" or "reinstate" operation, because it's added asynchronously
const indexOfWithdrawReinstate = operations.length - 1;
if (item.isDiscoverable) {
operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private'));
operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations', FeatureID.CanManagePolicies, true));
operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper', FeatureID.CanManageMappings, true));
if (item.isWithdrawn) {
operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate', FeatureID.ReinstateItem, true));
} else {
operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public'));
operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw', FeatureID.WithdrawItem, true));
}
operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete'));
operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move'));
if (item.isDiscoverable) {
operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private', FeatureID.CanMakePrivate, true));
} else {
operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public', FeatureID.CanMakePrivate, true));
}
operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete', FeatureID.CanDelete, true));
operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move', FeatureID.CanMove, true));
this.operations$.next(operations);
if (item.isWithdrawn) {
this.authorizationService.isAuthorized(FeatureID.ReinstateItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => {
const newOperations = [...this.operations$.value];
if (authorized) {
newOperations[indexOfWithdrawReinstate] = new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate');
observableFrom(operations).pipe(
mergeMap((operation) => {
if (hasValue(operation.featureID)) {
return this.authorizationService.isAuthorized(operation.featureID, item.self).pipe(
distinctUntilChanged(),
map((authorized) => new ItemOperation(operation.operationKey, operation.operationUrl, operation.featureID, !authorized, authorized))
);
} else {
newOperations[indexOfWithdrawReinstate] = undefined;
return [operation];
}
this.operations$.next(newOperations);
});
} else {
this.authorizationService.isAuthorized(FeatureID.WithdrawItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => {
const newOperations = [...this.operations$.value];
if (authorized) {
newOperations[indexOfWithdrawReinstate] = new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw');
} else {
newOperations[indexOfWithdrawReinstate] = undefined;
}
this.operations$.next(newOperations);
});
}
}),
toArray()
).subscribe((ops) => this.operations$.next(ops));
});
this.itemPageRoute$ = this.itemRD$.pipe(
getAllSucceededRemoteDataPayload(),

View File

@@ -3,7 +3,7 @@ import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/ro
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from './item-page.resolver';
import { Item } from '../core/shared/item.model';
import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { DsoPageSingleFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { AuthService } from '../core/auth/auth.service';
@@ -14,7 +14,7 @@ import { AuthService } from '../core/auth/auth.service';
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring administrator rights
*/
export class ItemPageAdministratorGuard extends DsoPageFeatureGuard<Item> {
export class ItemPageAdministratorGuard extends DsoPageSingleFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,

View File

@@ -9,6 +9,12 @@ import {
getFirstSucceededRemoteData
} from '../../../../core/shared/operators';
import { hasValue } from '../../../../shared/empty.util';
import { InjectionToken } from '@angular/core';
export const PAGINATED_RELATIONS_TO_ITEMS_OPERATOR = new InjectionToken<(thisId: string) => (source: Observable<RemoteData<PaginatedList<Relationship>>>) => Observable<RemoteData<PaginatedList<Item>>>>('paginatedRelationsToItems', {
providedIn: 'root',
factory: () => paginatedRelationsToItems
});
/**
* Operator for comparing arrays using a mapping function

View File

@@ -27,7 +27,10 @@ describe('MyDSpaceConfigurationService', () => {
scope: ''
});
const backendFilters = [new SearchFilter('f.namedresourcetype', ['another value']), new SearchFilter('f.dateSubmitted', ['[2013 TO 2018]'])];
const backendFilters = [
new SearchFilter('f.namedresourcetype', ['another value']),
new SearchFilter('f.dateSubmitted', ['[2013 TO 2018]'], 'equals')
];
const spy = jasmine.createSpyObj('RouteService', {
getQueryParameterValue: observableOf(value1),

View File

@@ -6,6 +6,8 @@
[configurationList]="(configurationList$ | async)"
[resultCount]="(resultsRD$ | async)?.payload.totalElements"
[viewModeList]="viewModeList"
[searchOptions]="(searchOptions$ | async)"
[sortOptions]="(sortOptions$ | async)"
[refreshFilters]="refreshFilters.asObservable()"
[inPlaceSearch]="inPlaceSearch"></ds-search-sidebar>
<div class="col-12 col-md-9">
@@ -28,6 +30,8 @@
[resultCount]="(resultsRD$ | async)?.payload.totalElements"
(toggleSidebar)="closeSidebar()"
[ngClass]="{'active': !(isSidebarCollapsed() | async)}"
[searchOptions]="(searchOptions$ | async)"
[sortOptions]="(sortOptions$ | async)"
[refreshFilters]="refreshFilters.asObservable()"
[inPlaceSearch]="inPlaceSearch">
</ds-search-sidebar>

View File

@@ -45,6 +45,7 @@ describe('MyDSpacePageComponent', () => {
pagination.id = 'mydspace-results-pagination';
pagination.currentPage = 1;
pagination.pageSize = 10;
const sortOption = { name: 'score', sortOrder: 'DESC', metadata: null };
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
const mockResults = createSuccessfulRemoteDataObject$(['test', 'data']);
const searchServiceStub = jasmine.createSpyObj('SearchService', {
@@ -52,7 +53,8 @@ describe('MyDSpacePageComponent', () => {
getEndpoint: observableOf('discover/search/objects'),
getSearchLink: '/mydspace',
getScopes: observableOf(['test-scope']),
setServiceOptions: {}
setServiceOptions: {},
getSearchConfigurationFor: createSuccessfulRemoteDataObject$({ sortOptions: [sortOption]})
});
const configurationParam = 'default';
const queryParam = 'test query';
@@ -188,4 +190,24 @@ describe('MyDSpacePageComponent', () => {
});
});
describe('when stable', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should have initialized the sortOptions$ observable', (done) => {
comp.sortOptions$.subscribe((sortOptions) => {
expect(sortOptions.length).toEqual(2);
expect(sortOptions[0]).toEqual(new SortOptions('score', SortDirection.ASC));
expect(sortOptions[1]).toEqual(new SortOptions('score', SortDirection.DESC));
done();
});
});
});
});

View File

@@ -8,7 +8,7 @@ import {
} from '@angular/core';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
import { map, switchMap, tap, } from 'rxjs/operators';
import { map, switchMap, tap } from 'rxjs/operators';
import { PaginatedList } from '../core/data/paginated-list.model';
import { RemoteData } from '../core/data/remote-data';
@@ -29,6 +29,8 @@ import { ViewMode } from '../core/shared/view-mode.model';
import { MyDSpaceRequest } from '../core/data/request.models';
import { SearchResult } from '../shared/search/search-result.model';
import { Context } from '../core/shared/context.model';
import { SortOptions } from '../core/cache/models/sort-options.model';
import { RouteService } from '../core/services/route.service';
export const MYDSPACE_ROUTE = '/mydspace';
export const SEARCH_CONFIG_SERVICE: InjectionToken<SearchConfigurationService> = new InjectionToken<SearchConfigurationService>('searchConfigurationService');
@@ -71,6 +73,11 @@ export class MyDSpacePageComponent implements OnInit {
*/
searchOptions$: Observable<PaginatedSearchOptions>;
/**
* The current available sort options
*/
sortOptions$: Observable<SortOptions[]>;
/**
* The current relevant scopes
*/
@@ -109,7 +116,8 @@ export class MyDSpacePageComponent implements OnInit {
constructor(private service: SearchService,
private sidebarService: SidebarService,
private windowService: HostWindowService,
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: MyDSpaceConfigurationService) {
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: MyDSpaceConfigurationService,
private routeService: RouteService) {
this.isXsOrSm$ = this.windowService.isXsOrSm();
this.service.setServiceOptions(MyDSpaceResponseParsingService, MyDSpaceRequest);
}
@@ -151,6 +159,12 @@ export class MyDSpacePageComponent implements OnInit {
})
);
const configuration$ = this.searchConfigService.getCurrentConfiguration('workspace');
const searchConfig$ = this.searchConfigService.getConfigurationSearchConfigObservable(configuration$, this.service);
this.sortOptions$ = this.searchConfigService.getConfigurationSortOptionsObservable(searchConfig$);
this.searchConfigService.initializeSortOptionsFromConfiguration(searchConfig$);
}
/**

View File

@@ -31,11 +31,14 @@
<ng-template #sidebarContent>
<ds-search-sidebar id="search-sidebar" *ngIf="!(isXsOrSm$ | async)"
[resultCount]="(resultsRD$ | async)?.payload?.totalElements"
[searchOptions]="(searchOptions$ | async)"
[sortOptions]="(sortOptions$ | async)"
[inPlaceSearch]="inPlaceSearch"></ds-search-sidebar>
<ds-search-sidebar id="search-sidebar-sm" *ngIf="(isXsOrSm$ | async)"
[resultCount]="(resultsRD$ | async)?.payload.totalElements"
(toggleSidebar)="closeSidebar()"
>
[searchOptions]="(searchOptions$ | async)"
[sortOptions]="(sortOptions$ | async)"
(toggleSidebar)="closeSidebar()">
</ds-search-sidebar>
</ng-template>

View File

@@ -40,12 +40,14 @@ const pagination: PaginationComponentOptions = new PaginationComponentOptions();
pagination.id = 'search-results-pagination';
pagination.currentPage = 1;
pagination.pageSize = 10;
const sortOption = { name: 'score', sortOrder: 'DESC', metadata: null };
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
const mockResults = createSuccessfulRemoteDataObject$(['test', 'data']);
const searchServiceStub = jasmine.createSpyObj('SearchService', {
search: mockResults,
getSearchLink: '/search',
getScopes: observableOf(['test-scope'])
getScopes: observableOf(['test-scope']),
getSearchConfigurationFor: createSuccessfulRemoteDataObject$({ sortOptions: [sortOption]})
});
const configurationParam = 'default';
const queryParam = 'test query';
@@ -181,4 +183,24 @@ describe('SearchComponent', () => {
});
});
describe('when stable', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should have initialized the sortOptions$ observable', (done) => {
comp.sortOptions$.subscribe((sortOptions) => {
expect(sortOptions.length).toEqual(2);
expect(sortOptions[0]).toEqual(new SortOptions('score', SortDirection.ASC));
expect(sortOptions[1]).toEqual(new SortOptions('score', SortDirection.DESC));
done();
});
});
});
});

View File

@@ -1,13 +1,13 @@
import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { startWith, switchMap, } from 'rxjs/operators';
import { startWith, switchMap } from 'rxjs/operators';
import { PaginatedList } from '../core/data/paginated-list.model';
import { RemoteData } from '../core/data/remote-data';
import { DSpaceObject } from '../core/shared/dspace-object.model';
import { pushInOut } from '../shared/animations/push';
import { HostWindowService } from '../shared/host-window.service';
import { SidebarService } from '../shared/sidebar/sidebar.service';
import { hasValue, isNotEmpty } from '../shared/empty.util';
import { hasValue, isEmpty } from '../shared/empty.util';
import { getFirstSucceededRemoteData } from '../core/shared/operators';
import { RouteService } from '../core/services/route.service';
import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component';
@@ -16,8 +16,9 @@ import { SearchResult } from '../shared/search/search-result.model';
import { SearchConfigurationService } from '../core/shared/search/search-configuration.service';
import { SearchService } from '../core/shared/search/search.service';
import { currentPath } from '../shared/utils/route.utils';
import { Router } from '@angular/router';
import { Router} from '@angular/router';
import { Context } from '../core/shared/context.model';
import { SortOptions } from '../core/cache/models/sort-options.model';
@Component({
selector: 'ds-search',
@@ -47,6 +48,11 @@ export class SearchComponent implements OnInit {
*/
searchOptions$: Observable<PaginatedSearchOptions>;
/**
* The current available sort options
*/
sortOptions$: Observable<SortOptions[]>;
/**
* The current relevant scopes
*/
@@ -129,9 +135,15 @@ export class SearchComponent implements OnInit {
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
switchMap((scopeId) => this.service.getScopes(scopeId))
);
if (!isNotEmpty(this.configuration$)) {
this.configuration$ = this.routeService.getRouteParameterValue('configuration');
if (isEmpty(this.configuration$)) {
this.configuration$ = this.searchConfigService.getCurrentConfiguration('default');
}
const searchConfig$ = this.searchConfigService.getConfigurationSearchConfigObservable(this.configuration$, this.service);
this.sortOptions$ = this.searchConfigService.getConfigurationSortOptionsObservable(searchConfig$);
this.searchConfigService.initializeSortOptionsFromConfiguration(searchConfig$);
}
/**

View File

@@ -210,4 +210,11 @@ describe('GroupFormComponent', () => {
});
});
describe('ngOnDestroy', () => {
it('does NOT call router.navigate', () => {
component.ngOnDestroy();
expect(router.navigate).toHaveBeenCalledTimes(0);
});
});
});

View File

@@ -405,7 +405,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
*/
@HostListener('window:beforeunload')
ngOnDestroy(): void {
this.onCancel();
this.groupDataService.cancelEditGroup();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}

View File

@@ -8,6 +8,20 @@ import {
TypedObject,
getResourceTypeValueFor
} from '../object-cache.reducer';
import { InjectionToken } from '@angular/core';
export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor<any>>('getDataServiceFor', {
providedIn: 'root',
factory: () => getDataServiceFor
});
export const LINK_DEFINITION_FACTORY = new InjectionToken<<T extends HALResource>(source: GenericConstructor<T>, linkName: keyof T['_links']) => LinkDefinition<T>>('getLinkDefinition', {
providedIn: 'root',
factory: () => getLinkDefinition
});
export const LINK_DEFINITION_MAP_FACTORY = new InjectionToken<<T extends HALResource>(source: GenericConstructor<T>) => Map<keyof T['_links'], LinkDefinition<T>>>('getLinkDefinitions', {
providedIn: 'root',
factory: () => getLinkDefinitions
});
const resolvedLinkKey = Symbol('resolvedLink');

View File

@@ -5,15 +5,9 @@ import { FindListOptions } from '../../data/request.models';
import { HALLink } from '../../shared/hal-link.model';
import { HALResource } from '../../shared/hal-resource.model';
import { ResourceType } from '../../shared/resource-type';
import * as decorators from './build-decorators';
import { LinkService } from './link.service';
const spyOnFunction = <T>(obj: T, func: keyof T) => {
const spy = jasmine.createSpy(func as string);
spyOnProperty(obj, func, 'get').and.returnValue(spy);
return spy;
};
import { DATA_SERVICE_FACTORY, LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY } from './build-decorators';
import { isEmpty } from 'rxjs/operators';
const TEST_MODEL = new ResourceType('testmodel');
let result: any;
@@ -51,7 +45,7 @@ let testDataService: TestDataService;
let testModel: TestModel;
xdescribe('LinkService', () => {
describe('LinkService', () => {
let service: LinkService;
beforeEach(() => {
@@ -76,6 +70,30 @@ xdescribe('LinkService', () => {
providers: [LinkService, {
provide: TestDataService,
useValue: testDataService
}, {
provide: DATA_SERVICE_FACTORY,
useValue: jasmine.createSpy('getDataServiceFor').and.returnValue(TestDataService),
}, {
provide: LINK_DEFINITION_FACTORY,
useValue: jasmine.createSpy('getLinkDefinition').and.returnValue({
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor'
}),
}, {
provide: LINK_DEFINITION_MAP_FACTORY,
useValue: jasmine.createSpy('getLinkDefinitions').and.returnValue([
{
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor',
},
{
resourceType: TEST_MODEL,
linkName: 'successor',
propertyName: 'successor',
}
]),
}]
});
service = TestBed.inject(LinkService);
@@ -84,12 +102,6 @@ xdescribe('LinkService', () => {
describe('resolveLink', () => {
describe(`when the linkdefinition concerns a single object`, () => {
beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor'
});
spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService);
service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor')));
});
it('should call dataservice.findByHref with the correct href and nested links', () => {
@@ -98,13 +110,12 @@ xdescribe('LinkService', () => {
});
describe(`when the linkdefinition concerns a list`, () => {
beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({
((service as any).getLinkDefinition as jasmine.Spy).and.returnValue({
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor',
isList: true
});
spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService);
service.resolveLink(testModel, followLink('predecessor', { some: 'options ' } as any, true, true, true, followLink('successor')));
});
it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => {
@@ -113,21 +124,15 @@ xdescribe('LinkService', () => {
});
describe('either way', () => {
beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor'
});
spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService);
result = service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor')));
});
it('should call getLinkDefinition with the correct model and link', () => {
expect(decorators.getLinkDefinition).toHaveBeenCalledWith(testModel.constructor as any, 'predecessor');
expect((service as any).getLinkDefinition).toHaveBeenCalledWith(testModel.constructor as any, 'predecessor');
});
it('should call getDataServiceFor with the correct resource type', () => {
expect(decorators.getDataServiceFor).toHaveBeenCalledWith(TEST_MODEL);
expect((service as any).getDataServiceFor).toHaveBeenCalledWith(TEST_MODEL);
});
it('should return the model with the resolved link', () => {
@@ -140,7 +145,7 @@ xdescribe('LinkService', () => {
describe(`when the specified link doesn't exist on the model's class`, () => {
beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue(undefined);
((service as any).getLinkDefinition as jasmine.Spy).and.returnValue(undefined);
});
it('should throw an error', () => {
expect(() => {
@@ -151,12 +156,7 @@ xdescribe('LinkService', () => {
describe(`when there is no dataservice for the resourcetype in the link`, () => {
beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor'
});
spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(undefined);
((service as any).getDataServiceFor as jasmine.Spy).and.returnValue(undefined);
});
it('should throw an error', () => {
expect(() => {
@@ -188,18 +188,6 @@ xdescribe('LinkService', () => {
beforeEach(() => {
testModel.predecessor = 'predecessor value' as any;
testModel.successor = 'successor value' as any;
spyOnFunction(decorators, 'getLinkDefinitions').and.returnValue([
{
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor',
},
{
resourceType: TEST_MODEL,
linkName: 'successor',
propertyName: 'successor',
}
]);
});
it('should return a new version of the object without any resolved links', () => {
@@ -231,16 +219,10 @@ xdescribe('LinkService', () => {
}
}
});
spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService);
});
describe('resolving the available link', () => {
beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor'
});
result = service.resolveLinks(testModel, followLink('predecessor'));
});
@@ -251,7 +233,7 @@ xdescribe('LinkService', () => {
describe('resolving the missing link', () => {
beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({
((service as any).getLinkDefinition as jasmine.Spy).and.returnValue({
resourceType: TEST_MODEL,
linkName: 'successor',
propertyName: 'successor'
@@ -259,8 +241,11 @@ xdescribe('LinkService', () => {
result = service.resolveLinks(testModel, followLink('successor'));
});
it('should return the model with no resolved link', () => {
expect(result.successor).toBeUndefined();
it('should resolve to an empty observable', (done) => {
result.successor.pipe(isEmpty()).subscribe((v) => {
expect(v).toEqual(true);
done();
});
});
});
});

View File

@@ -1,17 +1,18 @@
import { Injectable, Injector } from '@angular/core';
import { Inject, Injectable, Injector } from '@angular/core';
import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { GenericConstructor } from '../../shared/generic-constructor';
import { HALResource } from '../../shared/hal-resource.model';
import {
getDataServiceFor,
getLinkDefinition,
getLinkDefinitions,
DATA_SERVICE_FACTORY,
LINK_DEFINITION_FACTORY,
LINK_DEFINITION_MAP_FACTORY,
LinkDefinition
} from './build-decorators';
import { RemoteData } from '../../data/remote-data';
import { Observable } from 'rxjs/internal/Observable';
import { EMPTY } from 'rxjs';
import { ResourceType } from '../../shared/resource-type';
/**
* A Service to handle the resolving and removing
@@ -24,6 +25,9 @@ export class LinkService {
constructor(
protected parentInjector: Injector,
@Inject(DATA_SERVICE_FACTORY) private getDataServiceFor: (resourceType: ResourceType) => GenericConstructor<any>,
@Inject(LINK_DEFINITION_FACTORY) private getLinkDefinition: <T extends HALResource>(source: GenericConstructor<T>, linkName: keyof T['_links']) => LinkDefinition<T>,
@Inject(LINK_DEFINITION_MAP_FACTORY) private getLinkDefinitions: <T extends HALResource>(source: GenericConstructor<T>) => Map<keyof T['_links'], LinkDefinition<T>>,
) {
}
@@ -49,12 +53,12 @@ export class LinkService {
* @param linkToFollow the {@link FollowLinkConfig} to resolve
*/
public resolveLinkWithoutAttaching<T extends HALResource, U extends HALResource>(model, linkToFollow: FollowLinkConfig<T>): Observable<RemoteData<U>> {
const matchingLinkDef = getLinkDefinition(model.constructor, linkToFollow.name);
const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name);
if (hasNoValue(matchingLinkDef)) {
throw new Error(`followLink('${linkToFollow.name}') was used for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${linkToFollow.name}`);
} else {
const provider = getDataServiceFor(matchingLinkDef.resourceType);
const provider = this.getDataServiceFor(matchingLinkDef.resourceType);
if (hasNoValue(provider)) {
throw new Error(`The @link() for ${linkToFollow.name} on ${model.constructor.name} models uses the resource type ${matchingLinkDef.resourceType.value.toUpperCase()}, but there is no service with an @dataService(${matchingLinkDef.resourceType.value.toUpperCase()}) annotation in order to retrieve it`);
@@ -104,7 +108,7 @@ export class LinkService {
*/
public removeResolvedLinks<T extends HALResource>(model: T): T {
const result = Object.assign(new (model.constructor as GenericConstructor<T>)(), model);
const linkDefs = getLinkDefinitions(model.constructor as GenericConstructor<T>);
const linkDefs = this.getLinkDefinitions(model.constructor as GenericConstructor<T>);
if (isNotEmpty(linkDefs)) {
linkDefs.forEach((linkDef: LinkDefinition<T>) => {
result[linkDef.propertyName] = undefined;

View File

@@ -161,6 +161,7 @@ import { ShortLivedToken } from './auth/models/short-lived-token.model';
import { UsageReport } from './statistics/models/usage-report.model';
import { RootDataService } from './data/root-data.service';
import { Root } from './data/root.model';
import { SearchConfig } from './shared/search/search-filters/search-config.model';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -340,6 +341,7 @@ export const models =
Registration,
UsageReport,
Root,
SearchConfig
];
@NgModule({

View File

@@ -8,8 +8,11 @@ import { Registration } from '../shared/registration.model';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { TestScheduler } from 'rxjs/testing';
describe('EpersonRegistrationService', () => {
let testScheduler;
let service: EpersonRegistrationService;
let requestService: RequestService;
@@ -29,6 +32,12 @@ describe('EpersonRegistrationService', () => {
rd = createSuccessfulRemoteDataObject(registrationWithUser);
halService = new HALEndpointServiceStub('rest-url');
testScheduler = new TestScheduler((actual, expected) => {
// asserting the two objects are equal
// e.g. using chai.
expect(actual).toEqual(expected);
});
requestService = jasmine.createSpyObj('requestService', {
generateRequestId: 'request-id',
send: {},
@@ -36,7 +45,8 @@ describe('EpersonRegistrationService', () => {
{ a: Object.assign(new RequestEntry(), { response: new RestResponse(true, 200, 'Success') }) })
});
rdbService = jasmine.createSpyObj('rdbService', {
buildFromRequestUUID: observableOf(rd)
buildSingle: observableOf(rd),
buildFromRequestUUID: observableOf(rd),
});
service = new EpersonRegistrationService(
requestService,
@@ -86,8 +96,28 @@ describe('EpersonRegistrationService', () => {
user: registrationWithUser.user
})
}));
});
// tslint:disable:no-shadowed-variable
it('should use cached responses and /registrations/search/findByToken?', () => {
testScheduler.run(({ cold, expectObservable }) => {
rdbService.buildSingle.and.returnValue(cold('a', { a: rd }));
service.searchByToken('test-token');
expect(requestService.send).toHaveBeenCalledWith(
jasmine.objectContaining({
uuid: 'request-id', method: 'GET',
href: 'rest-url/registrations/search/findByToken?token=test-token',
}), true
);
expectObservable(rdbService.buildSingle.calls.argsFor(0)[0]).toBe('(a|)', {
a: 'rest-url/registrations/search/findByToken?token=test-token'
});
});
});
});
});
/**/

View File

@@ -3,7 +3,7 @@ import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { GetRequest, PostRequest } from './request.models';
import { Observable } from 'rxjs';
import { filter, find, map, take } from 'rxjs/operators';
import { filter, find, map, skipWhile } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { Registration } from '../shared/registration.model';
import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../shared/operators';
@@ -60,9 +60,9 @@ export class EpersonRegistrationService {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.getRegistrationEndpoint();
const href$ = this.getRegistrationEndpoint();
hrefObs.pipe(
href$.pipe(
find((href: string) => hasValue(href)),
map((href: string) => {
const request = new PostRequest(requestId, href, registration);
@@ -82,27 +82,28 @@ export class EpersonRegistrationService {
searchByToken(token: string): Observable<Registration> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.getTokenSearchEndpoint(token);
hrefObs.pipe(
const href$ = this.getTokenSearchEndpoint(token).pipe(
find((href: string) => hasValue(href)),
map((href: string) => {
const request = new GetRequest(requestId, href);
Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return RegistrationResponseParsingService;
}
});
this.requestService.send(request, true);
})
).subscribe();
);
return this.rdbService.buildFromRequestUUID<Registration>(requestId).pipe(
href$.subscribe((href: string) => {
const request = new GetRequest(requestId, href);
Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return RegistrationResponseParsingService;
}
});
this.requestService.send(request, true);
});
return this.rdbService.buildSingle<Registration>(href$).pipe(
skipWhile((rd: RemoteData<Registration>) => rd.isStale),
getFirstSucceededRemoteData(),
map((restResponse: RemoteData<Registration>) => {
return Object.assign(new Registration(), {email: restResponse.payload.email, token: token, user: restResponse.payload.user});
return Object.assign(new Registration(), {
email: restResponse.payload.email, token: token, user: restResponse.payload.user
});
}),
take(1),
);
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthService } from '../../../auth/auth.service';
@@ -13,7 +13,7 @@ import { FeatureID } from '../feature-id';
@Injectable({
providedIn: 'root'
})
export class CollectionAdministratorGuard extends FeatureAuthorizationGuard {
export class CollectionAdministratorGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthService } from '../../../auth/auth.service';
@@ -13,7 +13,7 @@ import { FeatureID } from '../feature-id';
@Injectable({
providedIn: 'root'
})
export class CommunityAdministratorGuard extends FeatureAuthorizationGuard {
export class CommunityAdministratorGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}

View File

@@ -4,14 +4,14 @@ import { RemoteData } from '../../remote-data';
import { Observable, of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { DsoPageFeatureGuard } from './dso-page-feature.guard';
import { DsoPageSingleFeatureGuard } from './dso-page-single-feature.guard';
import { FeatureID } from '../feature-id';
import { AuthService } from '../../../auth/auth.service';
/**
* Test implementation of abstract class DsoPageAdministratorGuard
* Test implementation of abstract class DsoPageSingleFeatureGuard
*/
class DsoPageFeatureGuardImpl extends DsoPageFeatureGuard<any> {
class DsoPageSingleFeatureGuardImpl extends DsoPageSingleFeatureGuard<any> {
constructor(protected resolver: Resolve<RemoteData<any>>,
protected authorizationService: AuthorizationDataService,
protected router: Router,
@@ -25,8 +25,8 @@ class DsoPageFeatureGuardImpl extends DsoPageFeatureGuard<any> {
}
}
describe('DsoPageAdministratorGuard', () => {
let guard: DsoPageFeatureGuard<any>;
describe('DsoPageSingleFeatureGuard', () => {
let guard: DsoPageSingleFeatureGuard<any>;
let authorizationService: AuthorizationDataService;
let router: Router;
let authService: AuthService;
@@ -62,7 +62,7 @@ describe('DsoPageAdministratorGuard', () => {
},
parent: parentRoute
};
guard = new DsoPageFeatureGuardImpl(resolver, authorizationService, router, authService, undefined);
guard = new DsoPageSingleFeatureGuardImpl(resolver, authorizationService, router, authService, undefined);
}
beforeEach(() => {

View File

@@ -0,0 +1,27 @@
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { DsoPageSomeFeatureGuard } from './dso-page-some-feature.guard';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { FeatureID } from '../feature-id';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
/**
* Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature
* This guard utilizes a resolver to retrieve the relevant object to check authorizations for
*/
export abstract class DsoPageSingleFeatureGuard<T extends DSpaceObject> extends DsoPageSomeFeatureGuard<T> {
/**
* The features to check authorization for
*/
getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return this.getFeatureID(route, state).pipe(
map((featureID) => [featureID]),
);
}
/**
* The type of feature to check authorization for
* Override this method to define a feature
*/
abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID>;
}

View File

@@ -0,0 +1,87 @@
import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { RemoteData } from '../../remote-data';
import { Observable, of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { FeatureID } from '../feature-id';
import { AuthService } from '../../../auth/auth.service';
import { DsoPageSomeFeatureGuard } from './dso-page-some-feature.guard';
/**
* Test implementation of abstract class DsoPageSomeFeatureGuard
*/
class DsoPageSomeFeatureGuardImpl extends DsoPageSomeFeatureGuard<any> {
constructor(protected resolver: Resolve<RemoteData<any>>,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService,
protected featureIDs: FeatureID[]) {
super(resolver, authorizationService, router, authService);
}
getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return observableOf(this.featureIDs);
}
}
describe('DsoPageSomeFeatureGuard', () => {
let guard: DsoPageSomeFeatureGuard<any>;
let authorizationService: AuthorizationDataService;
let router: Router;
let authService: AuthService;
let resolver: Resolve<RemoteData<any>>;
let object: DSpaceObject;
let route;
let parentRoute;
function init() {
object = {
self: 'test-selflink'
} as DSpaceObject;
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
router = jasmine.createSpyObj('router', {
parseUrl: {}
});
resolver = jasmine.createSpyObj('resolver', {
resolve: createSuccessfulRemoteDataObject$(object)
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true)
});
parentRoute = {
params: {
id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0'
}
};
route = {
params: {
},
parent: parentRoute
};
guard = new DsoPageSomeFeatureGuardImpl(resolver, authorizationService, router, authService, []);
}
beforeEach(() => {
init();
});
describe('getObjectUrl', () => {
it('should return the resolved object\'s selflink', (done) => {
guard.getObjectUrl(route, undefined).subscribe((selflink) => {
expect(selflink).toEqual(object.self);
done();
});
});
});
describe('getRouteWithDSOId', () => {
it('should return the route that has the UUID of the DSO', () => {
const foundRoute = (guard as any).getRouteWithDSOId(route);
expect(foundRoute).toBe(parentRoute);
});
});
});

View File

@@ -5,15 +5,15 @@ import { Observable } from 'rxjs';
import { getAllSucceededRemoteDataPayload } from '../../../shared/operators';
import { map } from 'rxjs/operators';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { AuthService } from '../../../auth/auth.service';
import { hasNoValue, hasValue } from '../../../../shared/empty.util';
import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard';
/**
* Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature
* Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for any specific feature in a list
* This guard utilizes a resolver to retrieve the relevant object to check authorizations for
*/
export abstract class DsoPageFeatureGuard<T extends DSpaceObject> extends FeatureAuthorizationGuard {
export abstract class DsoPageSomeFeatureGuard<T extends DSpaceObject> extends SomeFeatureAuthorizationGuard {
constructor(protected resolver: Resolve<RemoteData<T>>,
protected authorizationService: AuthorizationDataService,
protected router: Router,

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthService } from '../../../auth/auth.service';
@@ -13,7 +13,7 @@ import { FeatureID } from '../feature-id';
@Injectable({
providedIn: 'root'
})
export class GroupAdministratorGuard extends FeatureAuthorizationGuard {
export class GroupAdministratorGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}

View File

@@ -1,4 +1,4 @@
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { Observable, of as observableOf } from 'rxjs';
@@ -6,10 +6,10 @@ import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/ro
import { AuthService } from '../../../auth/auth.service';
/**
* Test implementation of abstract class FeatureAuthorizationGuard
* Test implementation of abstract class SingleFeatureAuthorizationGuard
* Provide the return values of the overwritten getters as constructor arguments
*/
class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard {
class SingleFeatureAuthorizationGuardImpl extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService,
@@ -32,8 +32,8 @@ class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard {
}
}
describe('FeatureAuthorizationGuard', () => {
let guard: FeatureAuthorizationGuard;
describe('SingleFeatureAuthorizationGuard', () => {
let guard: SingleFeatureAuthorizationGuard;
let authorizationService: AuthorizationDataService;
let router: Router;
let authService: AuthService;
@@ -56,7 +56,7 @@ describe('FeatureAuthorizationGuard', () => {
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true)
});
guard = new FeatureAuthorizationGuardImpl(authorizationService, router, authService, featureId, objectUrl, ePersonUuid);
guard = new SingleFeatureAuthorizationGuardImpl(authorizationService, router, authService, featureId, objectUrl, ePersonUuid);
}
beforeEach(() => {

View File

@@ -0,0 +1,27 @@
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { FeatureID } from '../feature-id';
import { Observable } from 'rxjs';
import { map} from 'rxjs/operators';
import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard';
/**
* 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 SingleFeatureAuthorizationGuard extends SomeFeatureAuthorizationGuard {
/**
* The features to check authorization for
*/
getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return this.getFeatureID(route, state).pipe(
map((featureID) => [featureID]),
);
}
/**
* The type of feature to check authorization for
* Override this method to define a feature
*/
abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID>;
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { FeatureID } from '../feature-id';
import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
@@ -13,7 +13,7 @@ import { AuthService } from '../../../auth/auth.service';
@Injectable({
providedIn: 'root'
})
export class SiteAdministratorGuard extends FeatureAuthorizationGuard {
export class SiteAdministratorGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}

View File

@@ -1,4 +1,4 @@
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { Injectable } from '@angular/core';
import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
@@ -13,7 +13,7 @@ import { AuthService } from '../../../auth/auth.service';
@Injectable({
providedIn: 'root'
})
export class SiteRegisterGuard extends FeatureAuthorizationGuard {
export class SiteRegisterGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}

View File

@@ -0,0 +1,110 @@
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { Observable, of as observableOf } from 'rxjs';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthService } from '../../../auth/auth.service';
import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard';
/**
* Test implementation of abstract class SomeFeatureAuthorizationGuard
* Provide the return values of the overwritten getters as constructor arguments
*/
class SomeFeatureAuthorizationGuardImpl extends SomeFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService,
protected featureIds: FeatureID[],
protected objectUrl: string,
protected ePersonUuid: string) {
super(authorizationService, router, authService);
}
getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return observableOf(this.featureIds);
}
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return observableOf(this.objectUrl);
}
getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return observableOf(this.ePersonUuid);
}
}
describe('SomeFeatureAuthorizationGuard', () => {
let guard: SomeFeatureAuthorizationGuard;
let authorizationService: AuthorizationDataService;
let router: Router;
let authService: AuthService;
let featureIds: FeatureID[];
let authorizedFeatureIds: FeatureID[];
let objectUrl: string;
let ePersonUuid: string;
function init() {
featureIds = [FeatureID.LoginOnBehalfOf, FeatureID.CanDelete];
authorizedFeatureIds = [];
objectUrl = 'fake-object-url';
ePersonUuid = 'fake-eperson-uuid';
authorizationService = Object.assign({
isAuthorized(featureId?: FeatureID): Observable<boolean> {
return observableOf(authorizedFeatureIds.indexOf(featureId) > -1);
}
});
router = jasmine.createSpyObj('router', {
parseUrl: {}
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true)
});
guard = new SomeFeatureAuthorizationGuardImpl(authorizationService, router, authService, featureIds, objectUrl, ePersonUuid);
}
beforeEach(() => {
init();
});
describe('canActivate', () => {
describe('when the user isn\'t authorized', () => {
beforeEach(() => {
authorizedFeatureIds = [];
});
it('should not return true', (done) => {
guard.canActivate(undefined, { url: 'current-url' } as any).subscribe((result) => {
expect(result).not.toEqual(true);
done();
});
});
});
describe('when the user is authorized for at least one of the guard\'s features', () => {
beforeEach(() => {
authorizedFeatureIds = [featureIds[0]];
});
it('should return true', (done) => {
guard.canActivate(undefined, { url: 'current-url' } as any).subscribe((result) => {
expect(result).toEqual(true);
done();
});
});
});
describe('when the user is authorized for all of the guard\'s features', () => {
beforeEach(() => {
authorizedFeatureIds = featureIds;
});
it('should return true', (done) => {
guard.canActivate(undefined, { url: 'current-url' } as any).subscribe((result) => {
expect(result).toEqual(true);
done();
});
});
});
});
});

View File

@@ -2,16 +2,16 @@ import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTr
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { returnForbiddenUrlTreeOrLoginOnFalse } from '../../../shared/operators';
import { returnForbiddenUrlTreeOrLoginOnAllFalse } from '../../../shared/operators';
import { switchMap } from 'rxjs/operators';
import { AuthService } from '../../../auth/auth.service';
/**
* 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.
* doesn't have authorized rights on any of the specified features and/or object.
* Override the desired getters in the parent class for checking specific authorization on a list of features and/or object.
*/
export abstract class FeatureAuthorizationGuard implements CanActivate {
export abstract class SomeFeatureAuthorizationGuard implements CanActivate {
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
@@ -22,17 +22,19 @@ export abstract class FeatureAuthorizationGuard implements CanActivate {
* Redirect the user to the unauthorized page when he/she's not authorized for the given feature
*/
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return observableCombineLatest(this.getFeatureID(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe(
switchMap(([featureID, objectUrl, ePersonUuid]) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid)),
returnForbiddenUrlTreeOrLoginOnFalse(this.router, this.authService, state.url)
return observableCombineLatest(this.getFeatureIDs(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe(
switchMap(([featureIDs, objectUrl, ePersonUuid]) =>
observableCombineLatest(...featureIDs.map((featureID) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid)))
),
returnForbiddenUrlTreeOrLoginOnAllFalse(this.router, this.authService, state.url)
);
}
/**
* The type of feature to check authorization for
* Override this method to define a feature
* The features to check authorization for
* Override this method to define a list of features
*/
abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID>;
abstract getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]>;
/**
* The URL of the object to check if the user has authorized rights for

View File

@@ -13,4 +13,11 @@ export enum FeatureID {
IsCollectionAdmin = 'isCollectionAdmin',
IsCommunityAdmin = 'isCommunityAdmin',
CanDownload = 'canDownload',
CanManageVersions = 'canManageVersions',
CanManageBitstreamBundles = 'canManageBitstreamBundles',
CanManageRelationships = 'canManageRelationships',
CanManageMappings = 'canManageMappings',
CanManagePolicies = 'canManagePolicies',
CanMakePrivate = 'canMakePrivate',
CanMove = 'canMove',
}

View File

@@ -1,5 +1,4 @@
import { of as observableOf } from 'rxjs';
import * as ItemRelationshipsUtils from '../../+item-page/simple/item-types/shared/item-relationships-utils';
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';
@@ -15,9 +14,9 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic
import { createSuccessfulRemoteDataObject, 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 { createPaginatedList, spyOnOperator } from '../../shared/testing/utils.test';
import { createPaginatedList } from '../../shared/testing/utils.test';
xdescribe('RelationshipService', () => {
describe('RelationshipService', () => {
let service: RelationshipService;
let requestService: RequestService;
@@ -132,7 +131,8 @@ xdescribe('RelationshipService', () => {
null,
null,
null,
null
null,
jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v),
);
}
@@ -195,8 +195,6 @@ xdescribe('RelationshipService', () => {
const rd$ = createSuccessfulRemoteDataObject$(relationsList);
spyOn(service, 'getItemRelationshipsByLabel').and.returnValue(rd$);
spyOnOperator(ItemRelationshipsUtils, 'paginatedRelationsToItems').and.returnValue((v) => v);
});
it('should call getItemRelationshipsByLabel with the correct params', (done) => {
@@ -225,7 +223,7 @@ xdescribe('RelationshipService', () => {
mockLabel,
mockOptions
).subscribe((result) => {
expect(ItemRelationshipsUtils.paginatedRelationsToItems).toHaveBeenCalledWith(mockItem.uuid);
expect((service as any).paginatedRelationsToItems).toHaveBeenCalledWith(mockItem.uuid);
done();
});
});

View File

@@ -1,11 +1,10 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Inject, Injectable } from '@angular/core';
import { MemoizedSelector, select, Store } from '@ngrx/store';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators';
import {
compareArraysUsingIds,
paginatedRelationsToItems,
compareArraysUsingIds, PAGINATED_RELATIONS_TO_ITEMS_OPERATOR,
relationsToItems
} from '../../+item-page/simple/item-types/shared/item-relationships-utils';
import { AppState, keySelector } from '../../app.reducer';
@@ -87,7 +86,8 @@ export class RelationshipService extends DataService<Relationship> {
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<Relationship>,
protected appStore: Store<AppState>) {
protected appStore: Store<AppState>,
@Inject(PAGINATED_RELATIONS_TO_ITEMS_OPERATOR) private paginatedRelationsToItems: (thisId: string) => (source: Observable<RemoteData<PaginatedList<Relationship>>>) => Observable<RemoteData<PaginatedList<Item>>>) {
super();
}
@@ -254,7 +254,7 @@ export class RelationshipService extends DataService<Relationship> {
* @param options
*/
getRelatedItemsByLabel(item: Item, label: string, options?: FindListOptions): Observable<RemoteData<PaginatedList<Item>>> {
return this.getItemRelationshipsByLabel(item, label, options, true, true, followLink('leftItem'), followLink('rightItem'), followLink('relationshipType')).pipe(paginatedRelationsToItems(item.uuid));
return this.getItemRelationshipsByLabel(item, label, options, true, true, followLink('leftItem'), followLink('rightItem'), followLink('relationshipType')).pipe(this.paginatedRelationsToItems(item.uuid));
}
/**

View File

@@ -1,6 +1,6 @@
import { Router, UrlTree } from '@angular/router';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { filter, find, map, mergeMap, switchMap, take, takeWhile, tap } from 'rxjs/operators';
import { debounceTime, filter, find, map, mergeMap, switchMap, take, takeWhile, tap } from 'rxjs/operators';
import { hasNoValue, hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util';
import { SearchResult } from '../../shared/search/search-result.model';
import { PaginatedList } from '../data/paginated-list.model';
@@ -15,6 +15,12 @@ import { DSpaceObject } from './dspace-object.model';
import { getForbiddenRoute, getPageNotFoundRoute } from '../../app-routing-paths';
import { getEndUserAgreementPath } from '../../info/info-routing-paths';
import { AuthService } from '../auth/auth.service';
import { InjectionToken } from '@angular/core';
export const DEBOUNCE_TIME_OPERATOR = new InjectionToken<<T>(dueTime: number) => (source: Observable<T>) => Observable<T>>('debounceTime', {
providedIn: 'root',
factory: () => debounceTime
});
/**
* This file contains custom RxJS operators that can be used in multiple places
@@ -201,10 +207,23 @@ export const redirectOn4xx = <T>(router: Router, authService: AuthService) =>
*/
export const returnForbiddenUrlTreeOrLoginOnFalse = (router: Router, authService: AuthService, redirectUrl: string) =>
(source: Observable<boolean>): Observable<boolean | UrlTree> =>
source.pipe(
map((authorized) => [authorized]),
returnForbiddenUrlTreeOrLoginOnAllFalse(router, authService, redirectUrl),
);
/**
* Operator that returns a UrlTree to a forbidden page or the login page when the booleans received are all false
* @param router The router used to navigate to a forbidden page
* @param authService The AuthService used to determine whether or not the user is logged in
* @param redirectUrl The URL to redirect back to after logging in
*/
export const returnForbiddenUrlTreeOrLoginOnAllFalse = (router: Router, authService: AuthService, redirectUrl: string) =>
(source: Observable<boolean[]>): Observable<boolean | UrlTree> =>
observableCombineLatest(source, authService.isAuthenticated()).pipe(
map(([authorized, authenticated]: [boolean, boolean]) => {
if (authorized) {
return authorized;
map(([authorizedList, authenticated]: [boolean[], boolean]) => {
if (authorizedList.some((b: boolean) => b === true)) {
return true;
} else {
if (authenticated) {
return router.parseUrl(getForbiddenRoute());

View File

@@ -23,7 +23,10 @@ describe('SearchConfigurationService', () => {
scope: ''
});
const backendFilters = [new SearchFilter('f.author', ['another value']), new SearchFilter('f.date', ['[2013 TO 2018]'])];
const backendFilters = [
new SearchFilter('f.author', ['another value']),
new SearchFilter('f.date', ['[2013 TO 2018]'], 'equals')
];
const routeService = jasmine.createSpyObj('RouteService', {
getQueryParameterValue: observableOf(value1),

View File

@@ -1,8 +1,15 @@
import { Injectable, OnDestroy } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { BehaviorSubject, combineLatest as observableCombineLatest, merge as observableMerge, Observable, Subscription } from 'rxjs';
import { filter, map, startWith } from 'rxjs/operators';
import {
BehaviorSubject,
combineLatest,
combineLatest as observableCombineLatest,
merge as observableMerge,
Observable,
Subscription
} from 'rxjs';
import { distinctUntilChanged, filter, map, startWith, switchMap, take } from 'rxjs/operators';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SearchOptions } from '../../../shared/search/search-options.model';
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
@@ -11,9 +18,15 @@ import { RemoteData } from '../../data/remote-data';
import { DSpaceObjectType } from '../dspace-object-type.model';
import { SortDirection, SortOptions } from '../../cache/models/sort-options.model';
import { RouteService } from '../../services/route.service';
import { getFirstSucceededRemoteData } from '../operators';
import {
getAllSucceededRemoteDataPayload,
getFirstSucceededRemoteData
} from '../operators';
import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { SearchConfig } from './search-filters/search-config.model';
import { SearchService } from './search.service';
import { of } from 'rxjs/internal/observable/of';
import { PaginationService } from '../../pagination/pagination.service';
/**
@@ -168,7 +181,7 @@ export class SearchConfigurationService implements OnDestroy {
if (hasNoValue(filters.find((f) => f.key === realKey))) {
const min = filterParams[realKey + '.min'] ? filterParams[realKey + '.min'][0] : '*';
const max = filterParams[realKey + '.max'] ? filterParams[realKey + '.max'][0] : '*';
filters.push(new SearchFilter(realKey, ['[' + min + ' TO ' + max + ']']));
filters.push(new SearchFilter(realKey, ['[' + min + ' TO ' + max + ']'], 'equals'));
}
} else {
filters.push(new SearchFilter(key, filterParams[key]));
@@ -194,6 +207,60 @@ export class SearchConfigurationService implements OnDestroy {
return this.routeService.getQueryParamsWithPrefix('f.');
}
/**
* Creates an observable of SearchConfig every time the configuration$ stream emits.
* @param configuration$
* @param service
*/
getConfigurationSearchConfigObservable(configuration$: Observable<string>, service: SearchService): Observable<SearchConfig> {
return configuration$.pipe(
distinctUntilChanged(),
switchMap((configuration) => service.getSearchConfigurationFor(null, configuration)),
getAllSucceededRemoteDataPayload());
}
/**
* Every time searchConfig change (after a configuration change) it update the navigation with the default sort option
* and emit the new paginateSearchOptions value.
* @param configuration$
* @param service
*/
initializeSortOptionsFromConfiguration(searchConfig$: Observable<SearchConfig>) {
const subscription = searchConfig$.pipe(switchMap((searchConfig) => combineLatest([
of(searchConfig),
this.paginatedSearchOptions.pipe(take(1))
]))).subscribe(([searchConfig, searchOptions]) => {
const field = searchConfig.sortOptions[0].name;
const direction = searchConfig.sortOptions[0].sortOrder.toLowerCase() === SortDirection.ASC.toLowerCase() ? SortDirection.ASC : SortDirection.DESC;
const updateValue = Object.assign(new PaginatedSearchOptions({}), searchOptions, {
sort: new SortOptions(field, direction)
});
this.paginationService.updateRoute(this.paginationID,
{
sortDirection: updateValue.sort.direction,
sortField: updateValue.sort.field,
});
this.paginatedSearchOptions.next(updateValue);
});
this.subs.push(subscription);
}
/**
* Creates an observable of available SortOptions[] every time the searchConfig$ stream emits.
* @param searchConfig$
* @param service
*/
getConfigurationSortOptionsObservable(searchConfig$: Observable<SearchConfig>): Observable<SortOptions[]> {
return searchConfig$.pipe(map((searchConfig) => {
const sortOptions = [];
searchConfig.sortOptions.forEach(sortOption => {
sortOptions.push(new SortOptions(sortOption.name, SortDirection.ASC));
sortOptions.push(new SortOptions(sortOption.name, SortDirection.DESC));
});
return sortOptions;
}));
}
/**
* Sets up a subscription to all necessary parameters to make sure the searchOptions emits a new value every time they update
* @param {SearchOptions} defaults Default values for when no parameters are available

View File

@@ -0,0 +1,76 @@
import { autoserialize, deserialize } from 'cerialize';
import { SEARCH_CONFIG } from './search-config.resource-type';
import { typedObject } from '../../../cache/builders/build-decorators';
import { CacheableObject } from '../../../cache/object-cache.reducer';
import { HALLink } from '../../hal-link.model';
import { ResourceType } from '../../resource-type';
/**
* The configuration for a search
*/
@typedObject
export class SearchConfig implements CacheableObject {
static type = SEARCH_CONFIG;
/**
* The id of this search configuration.
*/
@autoserialize
id: string;
/**
* The configured filters.
*/
@autoserialize
filters: FilterConfig[];
/**
* The configured sort options.
*/
@autoserialize
sortOptions: SortOption[];
/**
* The object type.
*/
@autoserialize
type: ResourceType;
/**
* The {@link HALLink}s for this Item
*/
@deserialize
_links: {
facets: HALLink;
objects: HALLink;
self: HALLink;
};
}
/**
* Interface to model filter's configuration.
*/
export interface FilterConfig {
filter: string;
hasFacets: boolean;
operators: OperatorConfig[];
openByDefault: boolean;
pageSize: number;
type: string;
}
/**
* Interface to model sort option's configuration.
*/
export interface SortOption {
name: string;
sortOrder: string;
}
/**
* Interface to model operator's configuration.
*/
export interface OperatorConfig {
operator: string;
}

View File

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

View File

@@ -240,5 +240,55 @@ describe('SearchService', () => {
expect((searchService as any).requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ href: requestUrl }), true);
});
});
describe('when getSearchConfigurationFor is called without a scope', () => {
const endPoint = 'http://endpoint.com/test/config';
beforeEach(() => {
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint));
spyOn((searchService as any).rdb, 'buildFromHref').and.callThrough();
/* tslint:disable:no-empty */
searchService.getSearchConfigurationFor(null).subscribe((t) => {
}); // subscribe to make sure all methods are called
/* tslint:enable:no-empty */
});
it('should call getEndpoint on the halService', () => {
expect((searchService as any).halService.getEndpoint).toHaveBeenCalled();
});
it('should send out the request on the request service', () => {
expect((searchService as any).requestService.send).toHaveBeenCalled();
});
it('should call send containing a request with the correct request url', () => {
expect((searchService as any).requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ href: endPoint }), true);
});
});
describe('when getSearchConfigurationFor is called with a scope', () => {
const endPoint = 'http://endpoint.com/test/config';
const scope = 'test';
const requestUrl = endPoint + '?scope=' + scope;
beforeEach(() => {
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint));
/* tslint:disable:no-empty */
searchService.getSearchConfigurationFor(scope).subscribe((t) => {
}); // subscribe to make sure all methods are called
/* tslint:enable:no-empty */
});
it('should call getEndpoint on the halService', () => {
expect((searchService as any).halService.getEndpoint).toHaveBeenCalled();
});
it('should send out the request on the request service', () => {
expect((searchService as any).requestService.send).toHaveBeenCalled();
});
it('should call send containing a request with the correct request url', () => {
expect((searchService as any).requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ href: requestUrl }), true);
});
});
});
});

View File

@@ -37,6 +37,7 @@ import { ListableObject } from '../../../shared/object-collection/shared/listabl
import { getSearchResultFor } from '../../../shared/search/search-result-element-decorator';
import { FacetConfigResponse } from '../../../shared/search/facet-config-response.model';
import { FacetValues } from '../../../shared/search/facet-values.model';
import { SearchConfig } from './search-filters/search-config.model';
import { PaginationService } from '../../pagination/pagination.service';
import { SearchConfigurationService } from './search-configuration.service';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
@@ -46,6 +47,12 @@ import { PaginationComponentOptions } from '../../../shared/pagination/paginatio
*/
@Injectable()
export class SearchService implements OnDestroy {
/**
* Endpoint link path for retrieving search configurations
*/
private configurationLinkPath = 'discover/search';
/**
* Endpoint link path for retrieving general search results
*/
@@ -229,6 +236,24 @@ export class SearchService implements OnDestroy {
);
}
private getConfigUrl(url: string, scope?: string, configurationName?: string) {
const args: string[] = [];
if (isNotEmpty(scope)) {
args.push(`scope=${scope}`);
}
if (isNotEmpty(configurationName)) {
args.push(`configuration=${configurationName}`);
}
if (isNotEmpty(args)) {
url = new URLCombiner(url, `?${args.join('&')}`).toString();
}
return url;
}
/**
* Request the filter configuration for a given scope or the whole repository
* @param {string} scope UUID of the object for which config the filter config is requested, when no scope is provided the configuration for the whole repository is loaded
@@ -237,33 +262,17 @@ export class SearchService implements OnDestroy {
*/
getConfig(scope?: string, configurationName?: string): Observable<RemoteData<SearchFilterConfig[]>> {
const href$ = this.halService.getEndpoint(this.facetLinkPathPrefix).pipe(
map((url: string) => {
const args: string[] = [];
if (isNotEmpty(scope)) {
args.push(`scope=${scope}`);
}
if (isNotEmpty(configurationName)) {
args.push(`configuration=${configurationName}`);
}
if (isNotEmpty(args)) {
url = new URLCombiner(url, `?${args.join('&')}`).toString();
}
return url;
}),
map((url: string) => this.getConfigUrl(url, scope, configurationName)),
);
href$.pipe(take(1)).subscribe((url: string) => {
let request = new this.request(this.requestService.generateRequestId(), url);
request = Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return FacetConfigResponseParsingService;
}
});
this.requestService.send(request, true);
let request = new this.request(this.requestService.generateRequestId(), url);
request = Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return FacetConfigResponseParsingService;
}
});
this.requestService.send(request, true);
});
return this.rdb.buildFromHref(href$).pipe(
@@ -398,6 +407,25 @@ export class SearchService implements OnDestroy {
});
}
/**
* Request the search configuration for a given scope or the whole repository
* @param {string} scope UUID of the object for which config the filter config is requested, when no scope is provided the configuration for the whole repository is loaded
* @param {string} configurationName the name of the configuration
* @returns {Observable<RemoteData<SearchConfig[]>>} The found configuration
*/
getSearchConfigurationFor(scope?: string, configurationName?: string ): Observable<RemoteData<SearchConfig>> {
const href$ = this.halService.getEndpoint(this.configurationLinkPath).pipe(
map((url: string) => this.getConfigUrl(url, scope, configurationName)),
);
href$.pipe(take(1)).subscribe((url: string) => {
const request = new this.request(this.requestService.generateRequestId(), url);
this.requestService.send(request, true);
});
return this.rdb.buildFromHref(href$);
}
/**
* @returns {string} The base path to the search page
*/

View File

@@ -11,6 +11,7 @@ import { Store } from '@ngrx/store';
import { CoreState } from '../../core/core.reducers';
import { RemoteData } from '../../core/data/remote-data';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
@Component({
selector: 'ds-forgot-password-form',
@@ -70,7 +71,9 @@ export class ForgotPasswordFormComponent {
*/
submit() {
if (!this.isInValid) {
this.ePersonDataService.patchPasswordWithToken(this.user, this.token, this.password).subscribe((response: RemoteData<EPerson>) => {
this.ePersonDataService.patchPasswordWithToken(this.user, this.token, this.password).pipe(
getFirstCompletedRemoteData()
).subscribe((response: RemoteData<EPerson>) => {
if (response.hasSucceeded) {
this.notificationsService.success(
this.translateService.instant(this.NOTIFICATIONS_PREFIX + '.success.title'),

View File

@@ -5,3 +5,7 @@
position: sticky;
}
}
:host {
z-index: var(--ds-nav-z-index);
}

View File

@@ -1,5 +1,4 @@
import { TestBed } from '@angular/core/testing';
import { BrowserKlaroService, COOKIE_MDFIELD } from './browser-klaro.service';
import { getMockTranslateService } from '../mocks/translate.service.mock';
import { of as observableOf } from 'rxjs';
@@ -13,7 +12,7 @@ import { getTestScheduler } from 'jasmine-marbles';
import { MetadataValue } from '../../core/shared/metadata.models';
import { cloneDeep } from 'lodash';
xdescribe('BrowserKlaroService', () => {
describe('BrowserKlaroService', () => {
let translateService;
let ePersonService;
let authService;
@@ -81,7 +80,7 @@ xdescribe('BrowserKlaroService', () => {
}
}
},
apps: [{
services: [{
name: appName,
purposes: [purpose]
}],

View File

@@ -21,8 +21,6 @@ import { createPaginatedList } from '../../../../testing/utils.test';
import { ExternalSourceService } from '../../../../../core/data/external-source.service';
import { LookupRelationService } from '../../../../../core/data/lookup-relation.service';
import { RemoteDataBuildService } from '../../../../../core/cache/builders/remote-data-build.service';
import { SubmissionService } from '../../../../../submission/submission.service';
import { SubmissionObjectDataService } from '../../../../../core/submission/submission-object-data.service';
import { WorkspaceItem } from '../../../../../core/submission/models/workspaceitem.model';
import { Collection } from '../../../../../core/shared/collection.model';
@@ -46,8 +44,6 @@ describe('DsDynamicLookupRelationModalComponent', () => {
let lookupRelationService;
let rdbService;
let submissionId;
let submissionService;
let submissionObjectDataService;
const externalSources = [
Object.assign(new ExternalSource(), {
@@ -99,12 +95,6 @@ describe('DsDynamicLookupRelationModalComponent', () => {
rdbService = jasmine.createSpyObj('rdbService', {
aggregate: createSuccessfulRemoteDataObject$(externalSources)
});
submissionService = jasmine.createSpyObj('SubmissionService', {
dispatchSave: jasmine.createSpy('dispatchSave')
});
submissionObjectDataService = jasmine.createSpyObj('SubmissionObjectDataService', {
findById: createSuccessfulRemoteDataObject$(testWSI)
});
submissionId = '1234';
}
@@ -129,8 +119,6 @@ describe('DsDynamicLookupRelationModalComponent', () => {
},
{ provide: RelationshipTypeService, useValue: {} },
{ provide: RemoteDataBuildService, useValue: rdbService },
{ provide: SubmissionService, useValue: submissionService },
{ provide: SubmissionObjectDataService, useValue: submissionObjectDataService },
{
provide: Store, useValue: {
// tslint:disable-next-line:no-empty

View File

@@ -12,11 +12,8 @@ import { RelationshipOptions } from '../../models/relationship-options.model';
import { SearchResult } from '../../../../search/search-result.model';
import { Item } from '../../../../../core/shared/item.model';
import {
getAllSucceededRemoteData,
getAllSucceededRemoteDataPayload,
getRemoteDataPayload
} from '../../../../../core/shared/operators';
import { AddRelationshipAction, RemoveRelationshipAction, UpdateRelationshipNameVariantAction } from './relationship.actions';
AddRelationshipAction, RemoveRelationshipAction, UpdateRelationshipNameVariantAction,
} from './relationship.actions';
import { RelationshipService } from '../../../../../core/data/relationship.service';
import { RelationshipTypeService } from '../../../../../core/data/relationship-type.service';
import { Store } from '@ngrx/store';
@@ -27,12 +24,7 @@ import { ExternalSource } from '../../../../../core/shared/external-source.model
import { ExternalSourceService } from '../../../../../core/data/external-source.service';
import { Router } from '@angular/router';
import { RemoteDataBuildService } from '../../../../../core/cache/builders/remote-data-build.service';
import { followLink } from '../../../../utils/follow-link-config.model';
import { SubmissionObject } from '../../../../../core/submission/models/submission-object.model';
import { Collection } from '../../../../../core/shared/collection.model';
import { SubmissionService } from '../../../../../submission/submission.service';
import { SubmissionObjectDataService } from '../../../../../core/submission/submission-object-data.service';
import { RemoteData } from '../../../../../core/data/remote-data';
import { getAllSucceededRemoteDataPayload } from '../../../../../core/shared/operators';
@Component({
selector: 'ds-dynamic-lookup-relation-modal',
@@ -122,10 +114,6 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
*/
totalExternal$: Observable<number[]>;
/**
* List of subscriptions to unsubscribe from
*/
private subs: Subscription[] = [];
constructor(
public modal: NgbActiveModal,
@@ -136,17 +124,14 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
private lookupRelationService: LookupRelationService,
private searchConfigService: SearchConfigurationService,
private rdbService: RemoteDataBuildService,
private submissionService: SubmissionService,
private submissionObjectService: SubmissionObjectDataService,
private zone: NgZone,
private store: Store<AppState>,
private router: Router
private router: Router,
) {
}
ngOnInit(): void {
this.setItem();
this.selection$ = this.selectableListService
.getSelectableList(this.listId)
.pipe(map((listState: SelectableListState) => hasValue(listState) && hasValue(listState.selection) ? listState.selection : []));
@@ -206,24 +191,6 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
});
}
/**
* Initialize this.item$ based on this.model.submissionId
*/
private setItem() {
const submissionObject$ = this.submissionObjectService
.findById(this.submissionId, true, true, followLink('item'), followLink('collection')).pipe(
getAllSucceededRemoteData(),
getRemoteDataPayload()
);
const item$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable<RemoteData<Item>>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload())));
const collection$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.collection as Observable<RemoteData<Collection>>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload())));
this.subs.push(item$.subscribe((item) => this.item = item));
this.subs.push(collection$.subscribe((collection) => this.collection = collection));
}
/**
* Add a subscription updating relationships with name variants
* @param sri The search result to track name variants for
@@ -279,8 +246,5 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
ngOnDestroy() {
this.router.navigate([], {});
Object.values(this.subMap).forEach((subscription) => subscription.unsubscribe());
this.subs
.filter((sub) => hasValue(sub))
.forEach((sub) => sub.unsubscribe());
}
}

View File

@@ -1,10 +1,7 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Store } from '@ngrx/store';
import { RelationshipEffects } from './relationship.effects';
import { AddRelationshipAction, RelationshipActionTypes, RemoveRelationshipAction } from './relationship.actions';
import { Item } from '../../../../../core/shared/item.model';
@@ -23,6 +20,9 @@ import { RequestService } from '../../../../../core/data/request.service';
import { NotificationsService } from '../../../../notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service';
import { cold, hot } from 'jasmine-marbles';
import { DEBOUNCE_TIME_OPERATOR } from '../../../../../core/shared/operators';
import { last } from 'rxjs/operators';
describe('RelationshipEffects', () => {
let relationEffects: RelationshipEffects;
@@ -51,7 +51,6 @@ describe('RelationshipEffects', () => {
let notificationsService;
let translateService;
let selectableListService;
let testScheduler: TestScheduler;
function init() {
testUUID1 = '20e24c2f-a00a-467c-bdee-c929e79bf08d';
@@ -131,6 +130,7 @@ describe('RelationshipEffects', () => {
{ provide: NotificationsService, useValue: notificationsService },
{ provide: TranslateService, useValue: translateService },
{ provide: SelectableListService, useValue: selectableListService },
{ provide: DEBOUNCE_TIME_OPERATOR, useValue: jasmine.createSpy('debounceTime').and.returnValue((v) => v.pipe(last())) },
],
});
}));
@@ -140,9 +140,6 @@ describe('RelationshipEffects', () => {
identifier = (relationEffects as any).createIdentifier(leftItem, rightItem, relationshipType.leftwardType);
spyOn((relationEffects as any), 'addRelationship').and.stub();
spyOn((relationEffects as any), 'removeRelationship').and.stub();
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
});
describe('mapLastActions$', () => {
@@ -151,15 +148,13 @@ describe('RelationshipEffects', () => {
let action;
it('should set the current value debounceMap and the value of the initialActionMap to ADD_RELATIONSHIP', () => {
testScheduler.run(({ hot, expectObservable, flush }) => {
action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
expectObservable(relationEffects.mapLastActions$).toBe('--b-|', { b: undefined });
flush();
// TODO check following expectations with the implementation
// expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type);
// expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
const expected = cold('--b-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type);
expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
});
@@ -172,14 +167,14 @@ describe('RelationshipEffects', () => {
});
it('should set the current value debounceMap to ADD_RELATIONSHIP but not change the value of the initialActionMap', () => {
testScheduler.run(({ hot, expectObservable, flush }) => {
action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
expectObservable(relationEffects.mapLastActions$).toBe('--b-|', { b: undefined });
flush();
expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType);
expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
const expected = cold('--b-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType);
expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
});
@@ -188,30 +183,26 @@ describe('RelationshipEffects', () => {
describe('When the last value in the debounceMap is also an ADD_RELATIONSHIP action', () => {
beforeEach(() => {
(relationEffects as any).initialActionMap[identifier] = RelationshipActionTypes.ADD_RELATIONSHIP;
((relationEffects as any).debounceTime as jasmine.Spy).and.returnValue((v) => v);
});
it('should call addRelationship on the effect', () => {
testScheduler.run(({ hot, expectObservable, flush }) => {
action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
expectObservable(relationEffects.mapLastActions$).toBe('--b-|', { b: undefined });
flush();
expect((relationEffects as any).addRelationship).toHaveBeenCalledWith(leftItem, rightItem, relationshipType.leftwardType, '1234', undefined);
});
action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
const expected = cold('--b-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).addRelationship).toHaveBeenCalledWith(leftItem, rightItem, relationshipType.leftwardType, '1234', undefined);
});
});
describe('When the last value in the debounceMap is instead a REMOVE_RELATIONSHIP action', () => {
it('should <b>not</b> call removeRelationship or addRelationship on the effect', () => {
testScheduler.run(({ hot, expectObservable, flush }) => {
const actiona = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
const actionb = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--ab-|', { a: actiona, b: actionb });
expectObservable(relationEffects.mapLastActions$).toBe('--bb-|', { b: undefined });
flush();
expect((relationEffects as any).addRelationship).not.toHaveBeenCalled();
expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled();
});
const actiona = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
const actionb = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--ab-|', { a: actiona, b: actionb });
const expected = cold('--bb-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).addRelationship).not.toHaveBeenCalled();
expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled();
});
});
});
@@ -222,15 +213,13 @@ describe('RelationshipEffects', () => {
let action;
it('should set the current value debounceMap and the value of the initialActionMap to REMOVE_RELATIONSHIP', () => {
testScheduler.run(({ hot, expectObservable, flush }) => {
action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
expectObservable(relationEffects.mapLastActions$).toBe('--b-|', { b: undefined });
flush();
// TODO check following expectations with the implementation
// expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type);
// expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
const expected = cold('--b-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type);
expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
});
@@ -238,20 +227,19 @@ describe('RelationshipEffects', () => {
let action;
const testActionType = 'TEST_TYPE';
beforeEach(() => {
((relationEffects as any).debounceTime as jasmine.Spy).and.returnValue((v) => v);
(relationEffects as any).initialActionMap[identifier] = testActionType;
(relationEffects as any).debounceMap[identifier] = new BehaviorSubject<string>(testActionType);
});
it('should set the current value debounceMap to REMOVE_RELATIONSHIP but not change the value of the initialActionMap', () => {
testScheduler.run(({ hot, expectObservable, flush }) => {
action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
expectObservable(relationEffects.mapLastActions$).toBe('--b-|', { b: undefined });
flush();
// TODO check following expectations with the implementation
// expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType);
// expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
const expected = cold('--b-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType);
expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
});
@@ -259,32 +247,28 @@ describe('RelationshipEffects', () => {
let action;
describe('When the last value in the debounceMap is also an REMOVE_RELATIONSHIP action', () => {
beforeEach(() => {
((relationEffects as any).debounceTime as jasmine.Spy).and.returnValue((v) => v);
(relationEffects as any).initialActionMap[identifier] = RelationshipActionTypes.REMOVE_RELATIONSHIP;
});
it('should call removeRelationship on the effect', () => {
testScheduler.run(({ hot, expectObservable, flush }) => {
action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('a', { a: action });
expectObservable(relationEffects.mapLastActions$).toBe('b', { b: undefined });
flush();
expect((relationEffects as any).removeRelationship).toHaveBeenCalledWith(leftItem, rightItem, relationshipType.leftwardType, '1234',);
});
action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
const expected = cold('--b-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).removeRelationship).toHaveBeenCalledWith(leftItem, rightItem, relationshipType.leftwardType, '1234',);
});
});
describe('When the last value in the debounceMap is instead a ADD_RELATIONSHIP action', () => {
it('should <b>not</b> call addRelationship or removeRelationship on the effect', () => {
testScheduler.run(({ hot, expectObservable, flush }) => {
const actionb = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
const actiona = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--ab-|', { a: actiona, b: actionb });
expectObservable(relationEffects.mapLastActions$).toBe('--bb-|', { b: undefined });
flush();
expect((relationEffects as any).addRelationship).not.toHaveBeenCalled();
expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled();
});
const actionb = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
const actiona = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--ab-|', { a: actiona, b: actionb });
const expected = cold('--bb-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).addRelationship).not.toHaveBeenCalled();
expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,11 +1,11 @@
import { Injectable } from '@angular/core';
import { Inject, Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { debounceTime, filter, map, mergeMap, switchMap, take } from 'rxjs/operators';
import { filter, map, mergeMap, switchMap, take } from 'rxjs/operators';
import { BehaviorSubject, Observable } from 'rxjs';
import { RelationshipService } from '../../../../../core/data/relationship.service';
import {
getRemoteDataPayload,
getFirstSucceededRemoteData
getFirstSucceededRemoteData, DEBOUNCE_TIME_OPERATOR
} from '../../../../../core/shared/operators';
import {
AddRelationshipAction,
@@ -71,7 +71,7 @@ export class RelationshipEffects {
this.initialActionMap[identifier] = action.type;
this.debounceMap[identifier] = new BehaviorSubject<string>(action.type);
this.debounceMap[identifier].pipe(
debounceTime(DEBOUNCE_TIME),
this.debounceTime(DEBOUNCE_TIME),
take(1)
).subscribe(
(type) => {
@@ -159,6 +159,7 @@ export class RelationshipEffects {
private notificationsService: NotificationsService,
private translateService: TranslateService,
private selectableListService: SelectableListService,
@Inject(DEBOUNCE_TIME_OPERATOR) private debounceTime: <T>(dueTime: number) => (source: Observable<T>) => Observable<T>,
) {
}

View File

@@ -1,15 +1,15 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ChangeDetectionStrategy, ComponentFactoryResolver, NO_ERRORS_SCHEMA } from '@angular/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { Context } from '../../core/shared/context.model';
import {
MetadataRepresentation,
MetadataRepresentationType
} from '../../core/shared/metadata-representation/metadata-representation.model';
import { MetadataRepresentationLoaderComponent } from './metadata-representation-loader.component';
import { PlainTextMetadataListElementComponent } from '../object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component';
import { spyOnExported } from '../testing/utils.test';
import { MetadataRepresentationDirective } from './metadata-representation.directive';
import * as metadataRepresentationDecorator from './metadata-representation.decorator';
import { METADATA_REPRESENTATION_COMPONENT_FACTORY } from './metadata-representation.decorator';
import { ThemeService } from '../theme-support/theme.service';
import { PlainTextMetadataListElementComponent } from '../object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component';
const testType = 'TestType';
const testContext = Context.Search;
@@ -29,16 +29,30 @@ class TestType implements MetadataRepresentation {
}
}
xdescribe('MetadataRepresentationLoaderComponent', () => {
describe('MetadataRepresentationLoaderComponent', () => {
let comp: MetadataRepresentationLoaderComponent;
let fixture: ComponentFixture<MetadataRepresentationLoaderComponent>;
let themeService: ThemeService;
const themeName = 'test-theme';
beforeEach(waitForAsync(() => {
themeService = jasmine.createSpyObj('themeService', {
getThemeName: themeName,
});
TestBed.configureTestingModule({
imports: [],
declarations: [MetadataRepresentationLoaderComponent, PlainTextMetadataListElementComponent, MetadataRepresentationDirective],
schemas: [NO_ERRORS_SCHEMA],
providers: [ComponentFactoryResolver]
providers: [
{
provide: METADATA_REPRESENTATION_COMPONENT_FACTORY,
useValue: jasmine.createSpy('getMetadataRepresentationComponent').and.returnValue(PlainTextMetadataListElementComponent)
},
{
provide: ThemeService,
useValue: themeService,
}
]
}).overrideComponent(MetadataRepresentationLoaderComponent, {
set: {
changeDetection: ChangeDetectionStrategy.Default,
@@ -53,15 +67,12 @@ xdescribe('MetadataRepresentationLoaderComponent', () => {
comp.mdRepresentation = new TestType();
comp.context = testContext;
spyOnExported(metadataRepresentationDecorator, 'getMetadataRepresentationComponent').and.returnValue(PlainTextMetadataListElementComponent);
fixture.detectChanges();
}));
describe('When the component is rendered', () => {
it('should call the getMetadataRepresentationComponent function with the right entity type, representation type and context', () => {
expect(metadataRepresentationDecorator.getMetadataRepresentationComponent).toHaveBeenCalledWith(testType, testRepresentationType, testContext);
expect((comp as any).getMetadataRepresentationComponent).toHaveBeenCalledWith(testType, testRepresentationType, testContext, themeName);
});
});
});

View File

@@ -1,6 +1,9 @@
import { Component, ComponentFactoryResolver, Input, OnInit, ViewChild } from '@angular/core';
import { MetadataRepresentation } from '../../core/shared/metadata-representation/metadata-representation.model';
import { getMetadataRepresentationComponent } from './metadata-representation.decorator';
import { Component, ComponentFactoryResolver, Inject, Input, OnInit, ViewChild } from '@angular/core';
import {
MetadataRepresentation,
MetadataRepresentationType
} from '../../core/shared/metadata-representation/metadata-representation.model';
import { METADATA_REPRESENTATION_COMPONENT_FACTORY } from './metadata-representation.decorator';
import { Context } from '../../core/shared/context.model';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import { MetadataRepresentationListElementComponent } from '../object-list/metadata-representation-list-element/metadata-representation-list-element.component';
@@ -45,7 +48,8 @@ export class MetadataRepresentationLoaderComponent implements OnInit {
constructor(
private componentFactoryResolver: ComponentFactoryResolver,
private themeService: ThemeService
private themeService: ThemeService,
@Inject(METADATA_REPRESENTATION_COMPONENT_FACTORY) private getMetadataRepresentationComponent: (entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context, theme: string) => GenericConstructor<any>,
) {
}
@@ -68,6 +72,6 @@ export class MetadataRepresentationLoaderComponent implements OnInit {
* @returns {string}
*/
private getComponent(): GenericConstructor<MetadataRepresentationListElementComponent> {
return getMetadataRepresentationComponent(this.mdRepresentation.itemType, this.mdRepresentation.representationType, this.context, this.themeService.getThemeName());
return this.getMetadataRepresentationComponent(this.mdRepresentation.itemType, this.mdRepresentation.representationType, this.context, this.themeService.getThemeName());
}
}

View File

@@ -1,6 +1,13 @@
import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model';
import { hasNoValue, hasValue } from '../empty.util';
import { Context } from '../../core/shared/context.model';
import { InjectionToken } from '@angular/core';
import { GenericConstructor } from '../../core/shared/generic-constructor';
export const METADATA_REPRESENTATION_COMPONENT_FACTORY = new InjectionToken<(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context, theme: string) => GenericConstructor<any>>('getMetadataRepresentationComponent', {
providedIn: 'root',
factory: () => getMetadataRepresentationComponent
});
export const map = new Map();

View File

@@ -97,8 +97,14 @@ export class EndpointMockingRestService extends DspaceRestService {
* the mock response if there is one, undefined otherwise
*/
private getMockData(urlStr: string): any {
const url = new URL(urlStr);
const key = url.pathname.slice(environment.rest.nameSpace.length);
let key;
if (this.mockResponseMap.has(urlStr)) {
key = urlStr;
} else {
// didn't find an exact match for the url, try to match only the endpoint without namespace and parameters
const url = new URL(urlStr);
key = url.pathname.slice(environment.rest.nameSpace.length);
}
if (this.mockResponseMap.has(key)) {
// parse and stringify to clone the object to ensure that any changes made
// to it afterwards don't affect future calls

View File

@@ -0,0 +1,51 @@
{
"_embedded": {
"authorizations": [
{
"id": "cd824a61-95be-4e16-bccd-51fea26707d0_canManageBitstreams_core.item_96715576-3748-4761-ad45-001646632963",
"type": "authorization",
"_links": {
"eperson": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageBitstreams_core.item_96715576-3748-4761-ad45-001646632963/eperson"
},
"feature": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageBitstreams_core.item_96715576-3748-4761-ad45-001646632963/feature"
},
"object": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageBitstreams_core.item_96715576-3748-4761-ad45-001646632963/object"
},
"self": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageBitstreams_core.item_96715576-3748-4761-ad45-001646632963"
}
},
"_embedded": {
"feature": {
"id": "canManageBitstreams",
"description": "It can be used to verify if the bitstreams of the specified objects can be managed",
"type": "feature",
"resourcetypes": [
"core.item",
"core.bundle"
],
"_links": {
"self": {
"href": "https://api7.dspace.org/server/api/authz/features/canManageBitstreams"
}
}
}
}
}
]
},
"_links": {
"self": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/search/object?uri=https://api7.dspace.org/server/api/core/items/96715576-3748-4761-ad45-001646632963&feature=canManageBitstreams"
}
},
"page": {
"size": 20,
"totalElements": 1,
"totalPages": 1,
"number": 0
}
}

View File

@@ -0,0 +1,50 @@
{
"_embedded": {
"authorizations": [
{
"id": "cd824a61-95be-4e16-bccd-51fea26707d0_canManageMappings_core.item_e98b0f27-5c19-49a0-960d-eb6ad5287067",
"type": "authorization",
"_links": {
"eperson": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageMappings_core.item_e98b0f27-5c19-49a0-960d-eb6ad5287067/eperson"
},
"feature": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageMappings_core.item_e98b0f27-5c19-49a0-960d-eb6ad5287067/feature"
},
"object": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageMappings_core.item_e98b0f27-5c19-49a0-960d-eb6ad5287067/object"
},
"self": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageMappings_core.item_e98b0f27-5c19-49a0-960d-eb6ad5287067"
}
},
"_embedded": {
"feature": {
"id": "canManageMappings",
"description": "It can be used to verify if the mappings of the specified objects can be managed",
"type": "feature",
"resourcetypes": [
"core.item"
],
"_links": {
"self": {
"href": "https://api7.dspace.org/server/api/authz/features/canManageMappings"
}
}
}
}
}
]
},
"_links": {
"self": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/search/object?uri=https://api7.dspace.org/server/api/core/items/e98b0f27-5c19-49a0-960d-eb6ad5287067&feature=canManageMappings"
}
},
"page": {
"size": 20,
"totalElements": 1,
"totalPages": 1,
"number": 0
}
}

View File

@@ -0,0 +1,50 @@
{
"_embedded": {
"authorizations": [
{
"id": "cd824a61-95be-4e16-bccd-51fea26707d0_canManageRelationships_core.item_047556d1-3d01-4c53-bc68-0cee7ad7ed4e",
"type": "authorization",
"_links": {
"eperson": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageRelationships_core.item_047556d1-3d01-4c53-bc68-0cee7ad7ed4e/eperson"
},
"feature": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageRelationships_core.item_047556d1-3d01-4c53-bc68-0cee7ad7ed4e/feature"
},
"object": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageRelationships_core.item_047556d1-3d01-4c53-bc68-0cee7ad7ed4e/object"
},
"self": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageRelationships_core.item_047556d1-3d01-4c53-bc68-0cee7ad7ed4e"
}
},
"_embedded": {
"feature": {
"id": "canManageRelationships",
"description": "It can be used to verify if the relationships of the specified objects can be managed",
"type": "feature",
"resourcetypes": [
"core.item"
],
"_links": {
"self": {
"href": "https://api7.dspace.org/server/api/authz/features/canManageRelationships"
}
}
}
}
}
]
},
"_links": {
"self": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/search/object?uri=https://api7.dspace.org/server/api/core/items/047556d1-3d01-4c53-bc68-0cee7ad7ed4e&feature=canManageRelationships"
}
},
"page": {
"size": 20,
"totalElements": 1,
"totalPages": 1,
"number": 0
}
}

View File

@@ -0,0 +1,50 @@
{
"_embedded": {
"authorizations": [
{
"id": "cd824a61-95be-4e16-bccd-51fea26707d0_canManageVersions_core.item_e98b0f27-5c19-49a0-960d-eb6ad5287067",
"type": "authorization",
"_links": {
"eperson": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageVersions_core.item_e98b0f27-5c19-49a0-960d-eb6ad5287067/eperson"
},
"feature": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageVersions_core.item_e98b0f27-5c19-49a0-960d-eb6ad5287067/feature"
},
"object": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageVersions_core.item_e98b0f27-5c19-49a0-960d-eb6ad5287067/object"
},
"self": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/cd824a61-95be-4e16-bccd-51fea26707d0_canManageVersions_core.item_e98b0f27-5c19-49a0-960d-eb6ad5287067"
}
},
"_embedded": {
"feature": {
"id": "canManageVersions",
"description": "It can be used to verify if the versions of the specified objects can be managed",
"type": "feature",
"resourcetypes": [
"core.item"
],
"_links": {
"self": {
"href": "https://api7.dspace.org/server/api/authz/features/canManageVersions"
}
}
}
}
}
]
},
"_links": {
"self": {
"href": "https://api7.dspace.org/server/api/authz/authorizations/search/object?uri=https://api7.dspace.org/server/api/core/items/e98b0f27-5c19-49a0-960d-eb6ad5287067&feature=canManageVersions"
}
},
"page": {
"size": 20,
"totalElements": 1,
"totalPages": 1,
"number": 0
}
}

View File

@@ -2,6 +2,10 @@ import { InjectionToken } from '@angular/core';
// import mockSubmissionResponse from './mock-submission-response.json';
// import mockPublicationResponse from './mock-publication-response.json';
// import mockUntypedItemResponse from './mock-untyped-item-response.json';
import mockFeatureItemCanManageBitstreamsResponse from './mock-feature-item-can-manage-bitstreams-response.json';
import mockFeatureItemCanManageRelationshipsResponse from './mock-feature-item-can-manage-relationships-response.json';
import mockFeatureItemCanManageVersionsResponse from './mock-feature-item-can-manage-versions-response.json';
import mockFeatureItemCanManageMappingsResponse from './mock-feature-item-can-manage-mappings-response.json';
export class ResponseMapMock extends Map<string, any> {}
@@ -16,4 +20,8 @@ export const mockResponseMap: ResponseMapMock = new Map([
// [ '/config/submissionforms/traditionalpageone', mockSubmissionResponse ]
// [ '/api/pid/find', mockPublicationResponse ],
// [ '/api/pid/find', mockUntypedItemResponse ],
[ 'https://api7.dspace.org/server/api/authz/authorizations/search/object?uri=https://api7.dspace.org/server/api/core/items/96715576-3748-4761-ad45-001646632963&feature=canManageBitstreams&embed=feature', mockFeatureItemCanManageBitstreamsResponse ],
[ 'https://api7.dspace.org/server/api/authz/authorizations/search/object?uri=https://api7.dspace.org/server/api/core/items/047556d1-3d01-4c53-bc68-0cee7ad7ed4e&feature=canManageRelationships&embed=feature', mockFeatureItemCanManageRelationshipsResponse ],
[ 'https://api7.dspace.org/server/api/authz/authorizations/search/object?uri=https://api7.dspace.org/server/api/core/items/e98b0f27-5c19-49a0-960d-eb6ad5287067&feature=canManageVersions&embed=feature', mockFeatureItemCanManageVersionsResponse ],
[ 'https://api7.dspace.org/server/api/authz/authorizations/search/object?uri=https://api7.dspace.org/server/api/core/items/e98b0f27-5c19-49a0-960d-eb6ad5287067&feature=canManageMappings&embed=feature', mockFeatureItemCanManageMappingsResponse ],
]);

View File

@@ -8,7 +8,12 @@ describe('PaginatedSearchOptions', () => {
let options: PaginatedSearchOptions;
const sortOptions = new SortOptions('test.field', SortDirection.DESC);
const pageOptions = Object.assign(new PaginationComponentOptions(), { pageSize: 40, page: 1 });
const filters = [new SearchFilter('f.test', ['value']), new SearchFilter('f.example', ['another value', 'second value'])];
const filters = [
new SearchFilter('f.test', ['value']),
new SearchFilter('f.example', ['another value', 'second value']), // should be split into two arguments, spaces should be URI-encoded
new SearchFilter('f.range', ['[2002 TO 2021]'], 'equals'), // value should be URI-encoded, ',equals' should not
];
const fixedFilter = 'f.fixed=1234,5678,equals'; // '=' and ',equals' should not be URI-encoded
const query = 'search query';
const scope = '0fde1ecb-82cc-425a-b600-ac3576d76b47';
const baseUrl = 'www.rest.com';
@@ -19,7 +24,8 @@ describe('PaginatedSearchOptions', () => {
filters: filters,
query: query,
scope: scope,
dsoTypes: [DSpaceObjectType.ITEM]
dsoTypes: [DSpaceObjectType.ITEM],
fixedFilter: fixedFilter,
});
});
@@ -31,12 +37,14 @@ describe('PaginatedSearchOptions', () => {
'sort=test.field,DESC&' +
'page=0&' +
'size=40&' +
'query=search query&' +
'f.fixed=1234%2C5678,equals&' +
'query=search%20query&' +
'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' +
'dsoType=ITEM&' +
'f.test=value&' +
'f.example=another value&' +
'f.example=second value'
'f.example=another%20value&' +
'f.example=second%20value&' +
'f.range=%5B2002%20TO%202021%5D,equals'
);
});

View File

@@ -35,7 +35,7 @@ export class SearchFilterComponent implements OnInit {
/**
* True when the filter is 100% collapsed in the UI
*/
closed = true;
closed: boolean;
/**
* Emits true when the filter is currently collapsed in the store

View File

@@ -8,18 +8,16 @@
::ng-deep
{
--ds-slider-handle-width: 18px;
html:not([dir=rtl]) .noUi-horizontal .noUi-handle {
right: calc(var(--ds-slider-handle-width) / -2);
}
.noUi-horizontal .noUi-handle {
width: var(--ds-slider-handle-width);
&:before {
left: calc(calc(calc(var(--ds-slider-handle-width) - 2) / 2) - 2);
left: calc(((var(--ds-slider-handle-width) - 2px) / 2) - 2px);
}
&:after {
left: calc(calc(calc(var(--ds-slider-handle-width) - 2) / 2) + 2);
left: calc(((var(--ds-slider-handle-width) - 2px) / 2) + 2px);
}
&:focus {
outline: none;

View File

@@ -56,7 +56,7 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
/**
* Fallback maximum for the range
*/
max = 2018;
max = new Date().getFullYear();
/**
* The current range of the filter

View File

@@ -4,13 +4,25 @@ import { SearchFilter } from './search-filter.model';
import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
describe('SearchOptions', () => {
let options: PaginatedSearchOptions;
const filters = [new SearchFilter('f.test', ['value']), new SearchFilter('f.example', ['another value', 'second value'])];
let options: SearchOptions;
const filters = [
new SearchFilter('f.test', ['value']),
new SearchFilter('f.example', ['another value', 'second value']), // should be split into two arguments, spaces should be URI-encoded
new SearchFilter('f.range', ['[2002 TO 2021]'], 'equals'), // value should be URI-encoded, ',equals' should not
];
const fixedFilter = 'f.fixed=1234,5678,equals'; // '=' and ',equals' should not be URI-encoded
const query = 'search query';
const scope = '0fde1ecb-82cc-425a-b600-ac3576d76b47';
const baseUrl = 'www.rest.com';
beforeEach(() => {
options = new SearchOptions({ filters: filters, query: query, scope: scope, dsoTypes: [DSpaceObjectType.ITEM] });
options = new SearchOptions({
filters: filters,
query: query,
scope: scope,
dsoTypes: [DSpaceObjectType.ITEM],
fixedFilter: fixedFilter,
});
});
describe('when toRestUrl is called', () => {
@@ -18,12 +30,14 @@ describe('SearchOptions', () => {
it('should generate a string with all parameters that are present', () => {
const outcome = options.toRestUrl(baseUrl);
expect(outcome).toEqual('www.rest.com?' +
'query=search query&' +
'f.fixed=1234%2C5678,equals&' +
'query=search%20query&' +
'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' +
'dsoType=ITEM&' +
'f.test=value&' +
'f.example=another value&' +
'f.example=second value'
'f.example=another%20value&' +
'f.example=second%20value&' +
'f.range=%5B2002%20TO%202021%5D,equals'
);
});

View File

@@ -1,4 +1,4 @@
import { isNotEmpty } from '../empty.util';
import { hasValue, isNotEmpty } from '../empty.util';
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { SearchFilter } from './search-filter.model';
import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
@@ -13,10 +13,15 @@ export class SearchOptions {
scope?: string;
query?: string;
dsoTypes?: DSpaceObjectType[];
filters?: any;
fixedFilter?: any;
filters?: SearchFilter[];
fixedFilter?: string;
constructor(options: {configuration?: string, scope?: string, query?: string, dsoTypes?: DSpaceObjectType[], filters?: SearchFilter[], fixedFilter?: any}) {
constructor(
options: {
configuration?: string, scope?: string, query?: string, dsoTypes?: DSpaceObjectType[], filters?: SearchFilter[],
fixedFilter?: string
}
) {
this.configuration = options.configuration;
this.scope = options.scope;
this.query = options.query;
@@ -33,27 +38,27 @@ export class SearchOptions {
*/
toRestUrl(url: string, args: string[] = []): string {
if (isNotEmpty(this.configuration)) {
args.push(`configuration=${this.configuration}`);
args.push(`configuration=${encodeURIComponent(this.configuration)}`);
}
if (isNotEmpty(this.fixedFilter)) {
args.push(this.fixedFilter);
args.push(this.encodedFixedFilter);
}
if (isNotEmpty(this.query)) {
args.push(`query=${this.query}`);
args.push(`query=${encodeURIComponent(this.query)}`);
}
if (isNotEmpty(this.scope)) {
args.push(`scope=${this.scope}`);
args.push(`scope=${encodeURIComponent(this.scope)}`);
}
if (isNotEmpty(this.dsoTypes)) {
this.dsoTypes.forEach((dsoType: string) => {
args.push(`dsoType=${dsoType}`);
args.push(`dsoType=${encodeURIComponent(dsoType)}`);
});
}
if (isNotEmpty(this.filters)) {
this.filters.forEach((filter: SearchFilter) => {
filter.values.forEach((value) => {
const filterValue = value.includes(',') ? `${value}` : value + (filter.operator ? ',' + filter.operator : '');
args.push(`${filter.key}=${filterValue}`);
args.push(`${filter.key}=${this.encodeFilterQueryValue(filterValue)}`);
});
});
}
@@ -62,4 +67,28 @@ export class SearchOptions {
}
return url;
}
get encodedFixedFilter(): string {
// expected format: 'arg=value'
// -> split the query agument into (arg=)(value) and only encode 'value'
const match = this.fixedFilter.match(/^([^=]+=)(.+)$/);
if (hasValue(match)) {
return match[1] + this.encodeFilterQueryValue(match[2]);
} else {
return this.encodeFilterQueryValue(this.fixedFilter);
}
}
encodeFilterQueryValue(filterQueryValue: string): string {
// expected format: 'value' or 'value,operator'
// -> split into (value)(,operator) and only encode 'value'
const match = filterQueryValue.match(/^(.*)(,\w+)$/);
if (hasValue(match)) {
return encodeURIComponent(match[1]) + match[2];
} else {
return encodeURIComponent(filterQueryValue);
}
}
}

View File

@@ -1,4 +1,4 @@
<ng-container *ngVar="(searchOptions$ | async) as config">
<ng-container *ngVar="searchOptions as config">
<h3>{{ 'search.sidebar.settings.title' | translate}}</h3>
<div class="result-order-settings">
<ds-sidebar-dropdown
@@ -7,7 +7,7 @@
[label]="'search.sidebar.settings.sort-by'"
(change)="reloadOrder($event)"
>
<option *ngFor="let sortOption of searchOptionPossibilities"
<option *ngFor="let sortOption of sortOptions"
[value]="sortOption.field + ',' + sortOption.direction.toString()"
[selected]="sortOption.field === config?.sort.field && sortOption.direction === (config?.sort.direction)? 'selected': null">
{{'sorting.' + sortOption.field + '.' + sortOption.direction | translate}}
@@ -15,4 +15,4 @@
</ds-sidebar-dropdown>
</div>
<ds-page-size-selector></ds-page-size-selector>
</ng-container>
</ng-container>

View File

@@ -12,7 +12,6 @@ import { EnumKeysPipe } from '../../utils/enum-keys-pipe';
import { By } from '@angular/platform-browser';
import { SearchFilterService } from '../../../core/shared/search/search-filter.service';
import { VarDirective } from '../../utils/var.directive';
import { take } from 'rxjs/operators';
import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component';
import { SidebarService } from '../../sidebar/sidebar.service';
import { SidebarServiceStub } from '../../testing/sidebar-service.stub';
@@ -91,7 +90,7 @@ describe('SearchSettingsComponent', () => {
provide: SEARCH_CONFIG_SERVICE,
useValue: {
paginatedSearchOptions: observableOf(paginatedSearchOptions),
getCurrentScope: observableOf('test-id')
getCurrentScope: observableOf('test-id'),
}
},
],
@@ -103,6 +102,14 @@ describe('SearchSettingsComponent', () => {
fixture = TestBed.createComponent(SearchSettingsComponent);
comp = fixture.componentInstance;
comp.sortOptions = [
new SortOptions('score', SortDirection.DESC),
new SortOptions('dc.title', SortDirection.ASC),
new SortOptions('dc.title', SortDirection.DESC)
];
comp.searchOptions = paginatedSearchOptions;
// SearchPageComponent test instance
fixture.detectChanges();
searchServiceObject = (comp as any).service;
@@ -111,34 +118,24 @@ describe('SearchSettingsComponent', () => {
});
it('it should show the order settings with the respective selectable options', (done) => {
(comp as any).searchOptions$.pipe(take(1)).subscribe((options) => {
fixture.detectChanges();
const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings'));
expect(orderSetting).toBeDefined();
const childElements = orderSetting.queryAll(By.css('option'));
expect(childElements.length).toEqual(comp.searchOptionPossibilities.length);
done();
});
it('it should show the order settings with the respective selectable options', () => {
fixture.detectChanges();
const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings'));
expect(orderSetting).toBeDefined();
const childElements = orderSetting.queryAll(By.css('option'));
expect(childElements.length).toEqual(comp.sortOptions.length);
});
it('it should show the size settings', (done) => {
(comp as any).searchOptions$.pipe(take(1)).subscribe((options) => {
fixture.detectChanges();
const pageSizeSetting = fixture.debugElement.query(By.css('page-size-settings'));
expect(pageSizeSetting).toBeDefined();
done();
}
);
it('it should show the size settings', () => {
fixture.detectChanges();
const pageSizeSetting = fixture.debugElement.query(By.css('page-size-settings'));
expect(pageSizeSetting).toBeDefined();
});
it('should have the proper order value selected by default', (done) => {
(comp as any).searchOptions$.pipe(take(1)).subscribe((options) => {
fixture.detectChanges();
const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings'));
const childElementToBeSelected = orderSetting.query(By.css('option[value="0"][selected="selected"]'));
expect(childElementToBeSelected).toBeDefined();
done();
});
it('should have the proper order value selected by default', () => {
fixture.detectChanges();
const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings'));
const childElementToBeSelected = orderSetting.query(By.css('option[value="score,DESC"][selected="selected"]'));
expect(childElementToBeSelected).toBeDefined();
});
});

View File

@@ -1,9 +1,8 @@
import { Component, Inject, OnInit } from '@angular/core';
import { Component, Inject, Input } from '@angular/core';
import { SearchService } from '../../../core/shared/search/search.service';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { ActivatedRoute, Router } from '@angular/router';
import { PaginatedSearchOptions } from '../paginated-search-options.model';
import { Observable } from 'rxjs';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component';
import { PaginationService } from '../../../core/pagination/pagination.service';
@@ -17,16 +16,17 @@ import { PaginationService } from '../../../core/pagination/pagination.service';
/**
* This component represents the part of the search sidebar that contains the general search settings.
*/
export class SearchSettingsComponent implements OnInit {
export class SearchSettingsComponent {
/**
* The configuration for the current paginated search results
*/
searchOptions$: Observable<PaginatedSearchOptions>;
@Input() searchOptions: PaginatedSearchOptions;
/**
* All sort options that are shown in the settings
*/
searchOptionPossibilities = [new SortOptions('score', SortDirection.DESC), new SortOptions('dc.title', SortDirection.ASC), new SortOptions('dc.title', SortDirection.DESC)];
@Input() sortOptions: SortOptions[];
constructor(private service: SearchService,
private route: ActivatedRoute,
@@ -35,13 +35,6 @@ export class SearchSettingsComponent implements OnInit {
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigurationService: SearchConfigurationService) {
}
/**
* Initialize paginated search options
*/
ngOnInit(): void {
this.searchOptions$ = this.searchConfigurationService.paginatedSearchOptions;
}
/**
* Method to change the current sort field and direction
* @param {Event} event Change event containing the sort direction and sort field

View File

@@ -12,7 +12,7 @@
<div class="sidebar-content">
<ds-search-switch-configuration [inPlaceSearch]="inPlaceSearch" *ngIf="configurationList" [configurationList]="configurationList"></ds-search-switch-configuration>
<ds-search-filters [refreshFilters]="refreshFilters" [inPlaceSearch]="inPlaceSearch"></ds-search-filters>
<ds-search-settings></ds-search-settings>
<ds-search-settings [searchOptions]="searchOptions" [sortOptions]="sortOptions"></ds-search-settings>
</div>
</div>
</div>

View File

@@ -2,6 +2,8 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
import { SearchConfigurationOption } from '../search-switch-configuration/search-configuration-option.model';
import { Observable } from 'rxjs';
import { PaginatedSearchOptions } from '../paginated-search-options.model';
import { SortOptions } from '../../../core/cache/models/sort-options.model';
/**
* This component renders a simple item page.
@@ -45,6 +47,16 @@ export class SearchSidebarComponent {
*/
@Input() inPlaceSearch;
/**
* The configuration for the current paginated search results
*/
@Input() searchOptions: PaginatedSearchOptions;
/**
* All sort options that are shown in the settings
*/
@Input() sortOptions: SortOptions[];
/**
* Emits when the search filters values may be stale, and so they must be refreshed.
*/

View File

@@ -1520,7 +1520,7 @@
"item.edit.breadcrumbs": "Edit Item",
"item.edit.tabs.disabled.tooltip": "You don't have permission to access this tab",
"item.edit.tabs.disabled.tooltip": "You're not authorized to access this tab",
"item.edit.tabs.mapper.head": "Collection Mapper",
@@ -1765,6 +1765,8 @@
"item.edit.tabs.status.buttons.reinstate.label": "Reinstate item into the repository",
"item.edit.tabs.status.buttons.unauthorized": "You're not authorized to perform this action",
"item.edit.tabs.status.buttons.withdraw.button": "Withdraw...",
"item.edit.tabs.status.buttons.withdraw.label": "Withdraw item from the repository",
@@ -3030,6 +3032,8 @@
"search.results.empty": "Your search returned no results.",
"default.search.results.head": "Search Results",
"search.sidebar.close": "Back to results",
@@ -3063,8 +3067,21 @@
"sorting.dc.title.DESC": "Title Descending",
"sorting.score.DESC": "Relevance",
"sorting.score.ASC": "Least Relevant",
"sorting.score.DESC": "Most Relevant",
"sorting.dc.date.issued.ASC": "Date Issued Ascending",
"sorting.dc.date.issued.DESC": "Date Issued Descending",
"sorting.dc.date.accessioned.ASC": "Accessioned Date Ascending",
"sorting.dc.date.accessioned.DESC": "Accessioned Date Descending",
"sorting.lastModified.ASC": "Last modified Ascending",
"sorting.lastModified.DESC": "Last modified Descending",
"statistics.title": "Statistics",

View File

@@ -78,4 +78,5 @@
--ds-breadcrumb-link-active-color: #{darken($cyan, 30%)};
--ds-slider-color: #{$green};
--ds-slider-handle-width: 18px;
}

View File

@@ -2657,10 +2657,10 @@ bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5.5:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0:
version "4.11.9"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828"
integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9:
version "4.12.0"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
bn.js@^5.0.0, bn.js@^5.1.1:
version "5.1.3"
@@ -2750,7 +2750,7 @@ braces@^3.0.1, braces@^3.0.2, braces@~3.0.2:
dependencies:
fill-range "^7.0.1"
brorand@^1.0.1:
brorand@^1.0.1, brorand@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=
@@ -4695,17 +4695,17 @@ element-resize-detector@^1.2.1:
batch-processor "1.0.0"
elliptic@^6.5.3:
version "6.5.3"
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6"
integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==
version "6.5.4"
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb"
integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==
dependencies:
bn.js "^4.4.0"
brorand "^1.0.1"
bn.js "^4.11.9"
brorand "^1.1.0"
hash.js "^1.0.0"
hmac-drbg "^1.0.0"
inherits "^2.0.1"
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.0"
hmac-drbg "^1.0.1"
inherits "^2.0.4"
minimalistic-assert "^1.0.1"
minimalistic-crypto-utils "^1.0.1"
emoji-regex@^7.0.1:
version "7.0.3"
@@ -5892,7 +5892,7 @@ hex-color-regex@^1.1.0:
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==
hmac-drbg@^1.0.0:
hmac-drbg@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=
@@ -7909,7 +7909,7 @@ minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1:
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
minimalistic-crypto-utils@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=
@@ -11659,9 +11659,9 @@ sshpk@^1.7.0:
tweetnacl "~0.14.0"
ssri@^6.0.0, ssri@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8"
integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==
version "6.0.2"
resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.2.tgz#157939134f20464e7301ddba3e90ffa8f7728ac5"
integrity sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==
dependencies:
figgy-pudding "^3.5.1"