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

View File

@@ -1,4 +1,6 @@
import { hasNoValue } from '../../shared/empty.util'; import { hasNoValue } from '../../shared/empty.util';
import { InjectionToken } from '@angular/core';
import { GenericConstructor } from '../../core/shared/generic-constructor';
export enum BrowseByType { export enum BrowseByType {
Title = 'title', Title = 'title',
@@ -8,6 +10,11 @@ export enum BrowseByType {
export const DEFAULT_BROWSE_BY_TYPE = BrowseByType.Metadata; 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(); 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 { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import * as decorator from './browse-by-decorator';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { environment } from '../../../environments/environment'; 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 comp: BrowseBySwitcherComponent;
let fixture: ComponentFixture<BrowseBySwitcherComponent>; let fixture: ComponentFixture<BrowseBySwitcherComponent>;
@@ -23,7 +22,8 @@ xdescribe('BrowseBySwitcherComponent', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [BrowseBySwitcherComponent], declarations: [BrowseBySwitcherComponent],
providers: [ 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] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();
@@ -32,7 +32,6 @@ xdescribe('BrowseBySwitcherComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(BrowseBySwitcherComponent); fixture = TestBed.createComponent(BrowseBySwitcherComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
spyOnProperty(decorator, 'getComponentByBrowseByType').and.returnValue(createSpy('getComponentByItemType'));
})); }));
types.forEach((type) => { types.forEach((type) => {
@@ -43,7 +42,7 @@ xdescribe('BrowseBySwitcherComponent', () => {
}); });
it(`should call getComponentByBrowseByType with type "${type.type}"`, () => { 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 { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { BrowseByTypeConfig } from '../../../config/browse-by-type-config.interface'; import { BrowseByTypeConfig } from '../../../config/browse-by-type-config.interface';
import { map } from 'rxjs/operators'; 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 { environment } from '../../../environments/environment';
import { GenericConstructor } from '../../core/shared/generic-constructor';
@Component({ @Component({
selector: 'ds-browse-by-switcher', selector: 'ds-browse-by-switcher',
@@ -20,7 +21,8 @@ export class BrowseBySwitcherComponent implements OnInit {
*/ */
browseByComponent: Observable<any>; 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; const id = params.id;
return environment.browseBy.types.find((config: BrowseByTypeConfig) => config.id === 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 { CollectionPageResolver } from './collection-page.resolver';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { Observable, of as observableOf } from 'rxjs'; 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 { FeatureID } from '../core/data/feature-authorization/feature-id';
import { AuthService } from '../core/auth/auth.service'; 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 * 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, constructor(protected resolver: CollectionPageResolver,
protected authorizationService: AuthorizationDataService, protected authorizationService: AuthorizationDataService,
protected router: Router, protected router: Router,

View File

@@ -4,7 +4,7 @@ import { Community } from '../core/shared/community.model';
import { CommunityPageResolver } from './community-page.resolver'; import { CommunityPageResolver } from './community-page.resolver';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { Observable, of as observableOf } from 'rxjs'; 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 { FeatureID } from '../core/data/feature-authorization/feature-id';
import { AuthService } from '../core/auth/auth.service'; 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 * 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, constructor(protected resolver: CommunityPageResolver,
protected authorizationService: AuthorizationDataService, protected authorizationService: AuthorizationDataService,
protected router: Router, protected router: Router,

View File

@@ -31,8 +31,13 @@ import {
} from './edit-item-page.routing-paths'; } from './edit-item-page.routing-paths';
import { ItemPageReinstateGuard } from './item-page-reinstate.guard'; import { ItemPageReinstateGuard } from './item-page-reinstate.guard';
import { ItemPageWithdrawGuard } from './item-page-withdraw.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 { 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 * 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', path: 'status',
component: ItemStatusComponent, component: ItemStatusComponent,
data: { title: 'item.edit.tabs.status.title', showBreadcrumbs: true }, data: { title: 'item.edit.tabs.status.title', showBreadcrumbs: true },
canActivate: [ItemPageAdministratorGuard] canActivate: [ItemPageStatusGuard]
}, },
{ {
path: 'bitstreams', path: 'bitstreams',
component: ItemBitstreamsComponent, component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.bitstreams.title', showBreadcrumbs: true }, data: { title: 'item.edit.tabs.bitstreams.title', showBreadcrumbs: true },
canActivate: [ItemPageAdministratorGuard] canActivate: [ItemPageBitstreamsGuard]
}, },
{ {
path: 'metadata', path: 'metadata',
component: ItemMetadataComponent, component: ItemMetadataComponent,
data: { title: 'item.edit.tabs.metadata.title', showBreadcrumbs: true }, data: { title: 'item.edit.tabs.metadata.title', showBreadcrumbs: true },
canActivate: [ItemPageEditMetadataGuard] canActivate: [ItemPageMetadataGuard]
}, },
{ {
path: 'relationships', path: 'relationships',
component: ItemRelationshipsComponent, component: ItemRelationshipsComponent,
data: { title: 'item.edit.tabs.relationships.title', showBreadcrumbs: true }, data: { title: 'item.edit.tabs.relationships.title', showBreadcrumbs: true },
canActivate: [ItemPageEditMetadataGuard] canActivate: [ItemPageRelationshipsGuard]
}, },
/* TODO - uncomment & fix when view page exists /* TODO - uncomment & fix when view page exists
{ {
@@ -96,13 +101,13 @@ import { ItemPageAdministratorGuard } from '../item-page-administrator.guard';
path: 'versionhistory', path: 'versionhistory',
component: ItemVersionHistoryComponent, component: ItemVersionHistoryComponent,
data: { title: 'item.edit.tabs.versionhistory.title', showBreadcrumbs: true }, data: { title: 'item.edit.tabs.versionhistory.title', showBreadcrumbs: true },
canActivate: [ItemPageAdministratorGuard] canActivate: [ItemPageVersionHistoryGuard]
}, },
{ {
path: 'mapper', path: 'mapper',
component: ItemCollectionMapperComponent, component: ItemCollectionMapperComponent,
data: { title: 'item.edit.tabs.item-mapper.title', showBreadcrumbs: true }, 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, ItemPageReinstateGuard,
ItemPageWithdrawGuard, ItemPageWithdrawGuard,
ItemPageAdministratorGuard, ItemPageAdministratorGuard,
ItemPageEditMetadataGuard, ItemPageMetadataGuard,
ItemPageStatusGuard,
ItemPageBitstreamsGuard,
ItemPageRelationshipsGuard,
ItemPageVersionHistoryGuard,
ItemPageCollectionMapperGuard,
] ]
}) })
export class EditItemPageRoutingModule { export class EditItemPageRoutingModule {

View File

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

View File

@@ -28,19 +28,19 @@ describe('ItemOperationComponent', () => {
}); });
it('should render operation row', () => { 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'); expect(span.textContent).toContain('item.edit.tabs.status.buttons.key1.label');
const link = fixture.debugElement.query(By.css('a')).nativeElement; const button = fixture.debugElement.query(By.css('button')).nativeElement;
expect(link.href).toContain('url1'); expect(button.textContent).toContain('item.edit.tabs.status.buttons.key1.button');
expect(link.textContent).toContain('item.edit.tabs.status.buttons.key1.button');
}); });
it('should render disabled operation row', () => { it('should render disabled operation row', () => {
itemOperation.setDisabled(true); itemOperation.setDisabled(true);
fixture.detectChanges(); 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'); expect(span.textContent).toContain('item.edit.tabs.status.buttons.key1.label');
const span2 = fixture.debugElement.query(By.css('span.btn-danger')).nativeElement; const button = fixture.debugElement.query(By.css('button')).nativeElement;
expect(span2.textContent).toContain('item.edit.tabs.status.buttons.key1.button'); 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 * 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. * when performing the action and an option to disable the operation.
@@ -7,11 +9,15 @@ export class ItemOperation {
operationKey: string; operationKey: string;
operationUrl: string; operationUrl: string;
disabled: boolean; 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.operationKey = operationKey;
this.operationUrl = operationUrl; 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 { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from './item-page.resolver'; import { ItemPageResolver } from '../item-page.resolver';
import { Item } from '../core/shared/item.model'; 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 { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { AuthService } from '../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
@Injectable({ @Injectable({
providedIn: 'root' 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 * 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, constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService, protected authorizationService: AuthorizationDataService,
protected router: Router, protected router: Router,

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'; 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 { Item } from '../../core/shared/item.model';
import { ItemPageResolver } from '../item-page.resolver'; import { ItemPageResolver } from '../item-page.resolver';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; 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 * 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, constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService, protected authorizationService: AuthorizationDataService,
protected router: Router, 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 { Item } from '../../core/shared/item.model';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ItemPageResolver } from '../item-page.resolver'; 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 * 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, constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService, protected authorizationService: AuthorizationDataService,
protected router: Router, protected router: Router,

View File

@@ -18,7 +18,7 @@ import { RelationshipType } from '../../../../core/shared/item-relationships/rel
import { import {
getAllSucceededRemoteData, getAllSucceededRemoteData,
getRemoteDataPayload, getRemoteDataPayload,
getFirstSucceededRemoteData, getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload,
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
import { DsDynamicLookupRelationModalComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component'; import { 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 { followLink } from '../../../../shared/utils/follow-link-config.model';
import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { Collection } from '../../../../core/shared/collection.model';
@Component({ @Component({
selector: 'ds-edit-relationship-list', selector: 'ds-edit-relationship-list',
@@ -146,6 +147,11 @@ export class EditRelationshipListComponent implements OnInit {
modalComp.repeatable = true; modalComp.repeatable = true;
modalComp.listId = this.listId; modalComp.listId = this.listId;
modalComp.item = this.item; modalComp.item = this.item;
this.item.owningCollection.pipe(
getFirstSucceededRemoteDataPayload()
).subscribe((collection: Collection) => {
modalComp.collection = collection;
});
modalComp.select = (...selectableObjects: SearchResult<Item>[]) => { modalComp.select = (...selectableObjects: SearchResult<Item>[]) => {
selectableObjects.forEach((searchResult) => { selectableObjects.forEach((searchResult) => {
const relatedItem: Item = searchResult.indexableObject; 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 { Item } from '../../../core/shared/item.model';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { ItemOperation } from '../item-operation/itemOperation.model'; import { ItemOperation } from '../item-operation/itemOperation.model';
import { distinctUntilChanged, first, map } from 'rxjs/operators'; import { distinctUntilChanged, first, map, mergeMap, toArray } from 'rxjs/operators';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable, from as observableFrom } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths'; import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; 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 The value is supposed to be a href for the button
*/ */
const operations = []; const operations = [];
operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations')); operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations', FeatureID.CanManagePolicies, true));
operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper')); operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper', FeatureID.CanManageMappings, true));
operations.push(undefined); if (item.isWithdrawn) {
// Store the index of the "withdraw" or "reinstate" operation, because it's added asynchronously operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate', FeatureID.ReinstateItem, true));
const indexOfWithdrawReinstate = operations.length - 1;
if (item.isDiscoverable) {
operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private'));
} else { } 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')); if (item.isDiscoverable) {
operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move')); 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); this.operations$.next(operations);
if (item.isWithdrawn) { observableFrom(operations).pipe(
this.authorizationService.isAuthorized(FeatureID.ReinstateItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => { mergeMap((operation) => {
const newOperations = [...this.operations$.value]; if (hasValue(operation.featureID)) {
if (authorized) { return this.authorizationService.isAuthorized(operation.featureID, item.self).pipe(
newOperations[indexOfWithdrawReinstate] = new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'); distinctUntilChanged(),
map((authorized) => new ItemOperation(operation.operationKey, operation.operationUrl, operation.featureID, !authorized, authorized))
);
} else { } 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( this.itemPageRoute$ = this.itemRD$.pipe(
getAllSucceededRemoteDataPayload(), 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 { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from './item-page.resolver'; import { ItemPageResolver } from './item-page.resolver';
import { Item } from '../core/shared/item.model'; 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 { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../core/data/feature-authorization/feature-id'; import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { AuthService } from '../core/auth/auth.service'; 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 * 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, constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService, protected authorizationService: AuthorizationDataService,
protected router: Router, protected router: Router,

View File

@@ -9,6 +9,12 @@ import {
getFirstSucceededRemoteData getFirstSucceededRemoteData
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { hasValue } from '../../../../shared/empty.util'; 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 * Operator for comparing arrays using a mapping function

View File

@@ -27,7 +27,10 @@ describe('MyDSpaceConfigurationService', () => {
scope: '' 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', { const spy = jasmine.createSpyObj('RouteService', {
getQueryParameterValue: observableOf(value1), getQueryParameterValue: observableOf(value1),

View File

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

View File

@@ -45,6 +45,7 @@ describe('MyDSpacePageComponent', () => {
pagination.id = 'mydspace-results-pagination'; pagination.id = 'mydspace-results-pagination';
pagination.currentPage = 1; pagination.currentPage = 1;
pagination.pageSize = 10; pagination.pageSize = 10;
const sortOption = { name: 'score', sortOrder: 'DESC', metadata: null };
const sort: SortOptions = new SortOptions('score', SortDirection.DESC); const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
const mockResults = createSuccessfulRemoteDataObject$(['test', 'data']); const mockResults = createSuccessfulRemoteDataObject$(['test', 'data']);
const searchServiceStub = jasmine.createSpyObj('SearchService', { const searchServiceStub = jasmine.createSpyObj('SearchService', {
@@ -52,7 +53,8 @@ describe('MyDSpacePageComponent', () => {
getEndpoint: observableOf('discover/search/objects'), getEndpoint: observableOf('discover/search/objects'),
getSearchLink: '/mydspace', getSearchLink: '/mydspace',
getScopes: observableOf(['test-scope']), getScopes: observableOf(['test-scope']),
setServiceOptions: {} setServiceOptions: {},
getSearchConfigurationFor: createSuccessfulRemoteDataObject$({ sortOptions: [sortOption]})
}); });
const configurationParam = 'default'; const configurationParam = 'default';
const queryParam = 'test query'; 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'; } from '@angular/core';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; 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 { PaginatedList } from '../core/data/paginated-list.model';
import { RemoteData } from '../core/data/remote-data'; 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 { MyDSpaceRequest } from '../core/data/request.models';
import { SearchResult } from '../shared/search/search-result.model'; import { SearchResult } from '../shared/search/search-result.model';
import { Context } from '../core/shared/context.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 MYDSPACE_ROUTE = '/mydspace';
export const SEARCH_CONFIG_SERVICE: InjectionToken<SearchConfigurationService> = new InjectionToken<SearchConfigurationService>('searchConfigurationService'); export const SEARCH_CONFIG_SERVICE: InjectionToken<SearchConfigurationService> = new InjectionToken<SearchConfigurationService>('searchConfigurationService');
@@ -71,6 +73,11 @@ export class MyDSpacePageComponent implements OnInit {
*/ */
searchOptions$: Observable<PaginatedSearchOptions>; searchOptions$: Observable<PaginatedSearchOptions>;
/**
* The current available sort options
*/
sortOptions$: Observable<SortOptions[]>;
/** /**
* The current relevant scopes * The current relevant scopes
*/ */
@@ -109,7 +116,8 @@ export class MyDSpacePageComponent implements OnInit {
constructor(private service: SearchService, constructor(private service: SearchService,
private sidebarService: SidebarService, private sidebarService: SidebarService,
private windowService: HostWindowService, 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.isXsOrSm$ = this.windowService.isXsOrSm();
this.service.setServiceOptions(MyDSpaceResponseParsingService, MyDSpaceRequest); 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> <ng-template #sidebarContent>
<ds-search-sidebar id="search-sidebar" *ngIf="!(isXsOrSm$ | async)" <ds-search-sidebar id="search-sidebar" *ngIf="!(isXsOrSm$ | async)"
[resultCount]="(resultsRD$ | async)?.payload?.totalElements" [resultCount]="(resultsRD$ | async)?.payload?.totalElements"
[searchOptions]="(searchOptions$ | async)"
[sortOptions]="(sortOptions$ | async)"
[inPlaceSearch]="inPlaceSearch"></ds-search-sidebar> [inPlaceSearch]="inPlaceSearch"></ds-search-sidebar>
<ds-search-sidebar id="search-sidebar-sm" *ngIf="(isXsOrSm$ | async)" <ds-search-sidebar id="search-sidebar-sm" *ngIf="(isXsOrSm$ | async)"
[resultCount]="(resultsRD$ | async)?.payload.totalElements" [resultCount]="(resultsRD$ | async)?.payload.totalElements"
(toggleSidebar)="closeSidebar()" [searchOptions]="(searchOptions$ | async)"
> [sortOptions]="(sortOptions$ | async)"
(toggleSidebar)="closeSidebar()">
</ds-search-sidebar> </ds-search-sidebar>
</ng-template> </ng-template>

View File

@@ -40,12 +40,14 @@ const pagination: PaginationComponentOptions = new PaginationComponentOptions();
pagination.id = 'search-results-pagination'; pagination.id = 'search-results-pagination';
pagination.currentPage = 1; pagination.currentPage = 1;
pagination.pageSize = 10; pagination.pageSize = 10;
const sortOption = { name: 'score', sortOrder: 'DESC', metadata: null };
const sort: SortOptions = new SortOptions('score', SortDirection.DESC); const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
const mockResults = createSuccessfulRemoteDataObject$(['test', 'data']); const mockResults = createSuccessfulRemoteDataObject$(['test', 'data']);
const searchServiceStub = jasmine.createSpyObj('SearchService', { const searchServiceStub = jasmine.createSpyObj('SearchService', {
search: mockResults, search: mockResults,
getSearchLink: '/search', getSearchLink: '/search',
getScopes: observableOf(['test-scope']) getScopes: observableOf(['test-scope']),
getSearchConfigurationFor: createSuccessfulRemoteDataObject$({ sortOptions: [sortOption]})
}); });
const configurationParam = 'default'; const configurationParam = 'default';
const queryParam = 'test query'; 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 { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core';
import { BehaviorSubject, Observable, Subscription } from 'rxjs'; 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 { PaginatedList } from '../core/data/paginated-list.model';
import { RemoteData } from '../core/data/remote-data'; import { RemoteData } from '../core/data/remote-data';
import { DSpaceObject } from '../core/shared/dspace-object.model'; import { DSpaceObject } from '../core/shared/dspace-object.model';
import { pushInOut } from '../shared/animations/push'; import { pushInOut } from '../shared/animations/push';
import { HostWindowService } from '../shared/host-window.service'; import { HostWindowService } from '../shared/host-window.service';
import { SidebarService } from '../shared/sidebar/sidebar.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 { getFirstSucceededRemoteData } from '../core/shared/operators';
import { RouteService } from '../core/services/route.service'; import { RouteService } from '../core/services/route.service';
import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component';
@@ -18,6 +18,7 @@ import { SearchService } from '../core/shared/search/search.service';
import { currentPath } from '../shared/utils/route.utils'; 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 { Context } from '../core/shared/context.model';
import { SortOptions } from '../core/cache/models/sort-options.model';
@Component({ @Component({
selector: 'ds-search', selector: 'ds-search',
@@ -47,6 +48,11 @@ export class SearchComponent implements OnInit {
*/ */
searchOptions$: Observable<PaginatedSearchOptions>; searchOptions$: Observable<PaginatedSearchOptions>;
/**
* The current available sort options
*/
sortOptions$: Observable<SortOptions[]>;
/** /**
* The current relevant scopes * The current relevant scopes
*/ */
@@ -129,9 +135,15 @@ export class SearchComponent implements OnInit {
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
switchMap((scopeId) => this.service.getScopes(scopeId)) switchMap((scopeId) => this.service.getScopes(scopeId))
); );
if (!isNotEmpty(this.configuration$)) { if (isEmpty(this.configuration$)) {
this.configuration$ = this.routeService.getRouteParameterValue('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') @HostListener('window:beforeunload')
ngOnDestroy(): void { ngOnDestroy(): void {
this.onCancel(); this.groupDataService.cancelEditGroup();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
} }

View File

@@ -8,6 +8,20 @@ import {
TypedObject, TypedObject,
getResourceTypeValueFor getResourceTypeValueFor
} from '../object-cache.reducer'; } 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'); const resolvedLinkKey = Symbol('resolvedLink');

View File

@@ -5,15 +5,9 @@ import { FindListOptions } from '../../data/request.models';
import { HALLink } from '../../shared/hal-link.model'; import { HALLink } from '../../shared/hal-link.model';
import { HALResource } from '../../shared/hal-resource.model'; import { HALResource } from '../../shared/hal-resource.model';
import { ResourceType } from '../../shared/resource-type'; import { ResourceType } from '../../shared/resource-type';
import * as decorators from './build-decorators';
import { LinkService } from './link.service'; import { LinkService } from './link.service';
import { DATA_SERVICE_FACTORY, LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY } from './build-decorators';
const spyOnFunction = <T>(obj: T, func: keyof T) => { import { isEmpty } from 'rxjs/operators';
const spy = jasmine.createSpy(func as string);
spyOnProperty(obj, func, 'get').and.returnValue(spy);
return spy;
};
const TEST_MODEL = new ResourceType('testmodel'); const TEST_MODEL = new ResourceType('testmodel');
let result: any; let result: any;
@@ -51,7 +45,7 @@ let testDataService: TestDataService;
let testModel: TestModel; let testModel: TestModel;
xdescribe('LinkService', () => { describe('LinkService', () => {
let service: LinkService; let service: LinkService;
beforeEach(() => { beforeEach(() => {
@@ -76,6 +70,30 @@ xdescribe('LinkService', () => {
providers: [LinkService, { providers: [LinkService, {
provide: TestDataService, provide: TestDataService,
useValue: 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); service = TestBed.inject(LinkService);
@@ -84,12 +102,6 @@ xdescribe('LinkService', () => {
describe('resolveLink', () => { describe('resolveLink', () => {
describe(`when the linkdefinition concerns a single object`, () => { describe(`when the linkdefinition concerns a single object`, () => {
beforeEach(() => { 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'))); service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor')));
}); });
it('should call dataservice.findByHref with the correct href and nested links', () => { 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`, () => { describe(`when the linkdefinition concerns a list`, () => {
beforeEach(() => { beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ ((service as any).getLinkDefinition as jasmine.Spy).and.returnValue({
resourceType: TEST_MODEL, resourceType: TEST_MODEL,
linkName: 'predecessor', linkName: 'predecessor',
propertyName: 'predecessor', propertyName: 'predecessor',
isList: true isList: true
}); });
spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService);
service.resolveLink(testModel, followLink('predecessor', { some: 'options ' } as any, true, true, true, followLink('successor'))); 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', () => { it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => {
@@ -113,21 +124,15 @@ xdescribe('LinkService', () => {
}); });
describe('either way', () => { describe('either way', () => {
beforeEach(() => { 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'))); result = service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor')));
}); });
it('should call getLinkDefinition with the correct model and link', () => { 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', () => { 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', () => { 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`, () => { describe(`when the specified link doesn't exist on the model's class`, () => {
beforeEach(() => { beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue(undefined); ((service as any).getLinkDefinition as jasmine.Spy).and.returnValue(undefined);
}); });
it('should throw an error', () => { it('should throw an error', () => {
expect(() => { expect(() => {
@@ -151,12 +156,7 @@ xdescribe('LinkService', () => {
describe(`when there is no dataservice for the resourcetype in the link`, () => { describe(`when there is no dataservice for the resourcetype in the link`, () => {
beforeEach(() => { beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ ((service as any).getDataServiceFor as jasmine.Spy).and.returnValue(undefined);
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor'
});
spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(undefined);
}); });
it('should throw an error', () => { it('should throw an error', () => {
expect(() => { expect(() => {
@@ -188,18 +188,6 @@ xdescribe('LinkService', () => {
beforeEach(() => { beforeEach(() => {
testModel.predecessor = 'predecessor value' as any; testModel.predecessor = 'predecessor value' as any;
testModel.successor = 'successor 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', () => { 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', () => { describe('resolving the available link', () => {
beforeEach(() => { beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor'
});
result = service.resolveLinks(testModel, followLink('predecessor')); result = service.resolveLinks(testModel, followLink('predecessor'));
}); });
@@ -251,7 +233,7 @@ xdescribe('LinkService', () => {
describe('resolving the missing link', () => { describe('resolving the missing link', () => {
beforeEach(() => { beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ ((service as any).getLinkDefinition as jasmine.Spy).and.returnValue({
resourceType: TEST_MODEL, resourceType: TEST_MODEL,
linkName: 'successor', linkName: 'successor',
propertyName: 'successor' propertyName: 'successor'
@@ -259,8 +241,11 @@ xdescribe('LinkService', () => {
result = service.resolveLinks(testModel, followLink('successor')); result = service.resolveLinks(testModel, followLink('successor'));
}); });
it('should return the model with no resolved link', () => { it('should resolve to an empty observable', (done) => {
expect(result.successor).toBeUndefined(); 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 { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { GenericConstructor } from '../../shared/generic-constructor'; import { GenericConstructor } from '../../shared/generic-constructor';
import { HALResource } from '../../shared/hal-resource.model'; import { HALResource } from '../../shared/hal-resource.model';
import { import {
getDataServiceFor, DATA_SERVICE_FACTORY,
getLinkDefinition, LINK_DEFINITION_FACTORY,
getLinkDefinitions, LINK_DEFINITION_MAP_FACTORY,
LinkDefinition LinkDefinition
} from './build-decorators'; } from './build-decorators';
import { RemoteData } from '../../data/remote-data'; import { RemoteData } from '../../data/remote-data';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { ResourceType } from '../../shared/resource-type';
/** /**
* A Service to handle the resolving and removing * A Service to handle the resolving and removing
@@ -24,6 +25,9 @@ export class LinkService {
constructor( constructor(
protected parentInjector: Injector, 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 * @param linkToFollow the {@link FollowLinkConfig} to resolve
*/ */
public resolveLinkWithoutAttaching<T extends HALResource, U extends HALResource>(model, linkToFollow: FollowLinkConfig<T>): Observable<RemoteData<U>> { 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)) { 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}`); 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 { } else {
const provider = getDataServiceFor(matchingLinkDef.resourceType); const provider = this.getDataServiceFor(matchingLinkDef.resourceType);
if (hasNoValue(provider)) { 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`); 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 { public removeResolvedLinks<T extends HALResource>(model: T): T {
const result = Object.assign(new (model.constructor as GenericConstructor<T>)(), model); 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)) { if (isNotEmpty(linkDefs)) {
linkDefs.forEach((linkDef: LinkDefinition<T>) => { linkDefs.forEach((linkDef: LinkDefinition<T>) => {
result[linkDef.propertyName] = undefined; 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 { UsageReport } from './statistics/models/usage-report.model';
import { RootDataService } from './data/root-data.service'; import { RootDataService } from './data/root-data.service';
import { Root } from './data/root.model'; 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 * When not in production, endpoint responses can be mocked for testing purposes
@@ -340,6 +341,7 @@ export const models =
Registration, Registration,
UsageReport, UsageReport,
Root, Root,
SearchConfig
]; ];
@NgModule({ @NgModule({

View File

@@ -8,8 +8,11 @@ import { Registration } from '../shared/registration.model';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { of as observableOf } from 'rxjs/internal/observable/of'; import { of as observableOf } from 'rxjs/internal/observable/of';
import { TestScheduler } from 'rxjs/testing';
describe('EpersonRegistrationService', () => { describe('EpersonRegistrationService', () => {
let testScheduler;
let service: EpersonRegistrationService; let service: EpersonRegistrationService;
let requestService: RequestService; let requestService: RequestService;
@@ -29,6 +32,12 @@ describe('EpersonRegistrationService', () => {
rd = createSuccessfulRemoteDataObject(registrationWithUser); rd = createSuccessfulRemoteDataObject(registrationWithUser);
halService = new HALEndpointServiceStub('rest-url'); 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', { requestService = jasmine.createSpyObj('requestService', {
generateRequestId: 'request-id', generateRequestId: 'request-id',
send: {}, send: {},
@@ -36,7 +45,8 @@ describe('EpersonRegistrationService', () => {
{ a: Object.assign(new RequestEntry(), { response: new RestResponse(true, 200, 'Success') }) }) { a: Object.assign(new RequestEntry(), { response: new RestResponse(true, 200, 'Success') }) })
}); });
rdbService = jasmine.createSpyObj('rdbService', { rdbService = jasmine.createSpyObj('rdbService', {
buildFromRequestUUID: observableOf(rd) buildSingle: observableOf(rd),
buildFromRequestUUID: observableOf(rd),
}); });
service = new EpersonRegistrationService( service = new EpersonRegistrationService(
requestService, requestService,
@@ -86,8 +96,28 @@ describe('EpersonRegistrationService', () => {
user: registrationWithUser.user 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 { HALEndpointService } from '../shared/hal-endpoint.service';
import { GetRequest, PostRequest } from './request.models'; import { GetRequest, PostRequest } from './request.models';
import { Observable } from 'rxjs'; 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 { hasValue, isNotEmpty } from '../../shared/empty.util';
import { Registration } from '../shared/registration.model'; import { Registration } from '../shared/registration.model';
import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../shared/operators'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../shared/operators';
@@ -60,9 +60,9 @@ export class EpersonRegistrationService {
const requestId = this.requestService.generateRequestId(); const requestId = this.requestService.generateRequestId();
const hrefObs = this.getRegistrationEndpoint(); const href$ = this.getRegistrationEndpoint();
hrefObs.pipe( href$.pipe(
find((href: string) => hasValue(href)), find((href: string) => hasValue(href)),
map((href: string) => { map((href: string) => {
const request = new PostRequest(requestId, href, registration); const request = new PostRequest(requestId, href, registration);
@@ -82,11 +82,11 @@ export class EpersonRegistrationService {
searchByToken(token: string): Observable<Registration> { searchByToken(token: string): Observable<Registration> {
const requestId = this.requestService.generateRequestId(); const requestId = this.requestService.generateRequestId();
const hrefObs = this.getTokenSearchEndpoint(token); const href$ = this.getTokenSearchEndpoint(token).pipe(
hrefObs.pipe(
find((href: string) => hasValue(href)), find((href: string) => hasValue(href)),
map((href: string) => { );
href$.subscribe((href: string) => {
const request = new GetRequest(requestId, href); const request = new GetRequest(requestId, href);
Object.assign(request, { Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> { getResponseParser(): GenericConstructor<ResponseParsingService> {
@@ -94,15 +94,16 @@ export class EpersonRegistrationService {
} }
}); });
this.requestService.send(request, true); this.requestService.send(request, true);
}) });
).subscribe();
return this.rdbService.buildFromRequestUUID<Registration>(requestId).pipe( return this.rdbService.buildSingle<Registration>(href$).pipe(
skipWhile((rd: RemoteData<Registration>) => rd.isStale),
getFirstSucceededRemoteData(), getFirstSucceededRemoteData(),
map((restResponse: RemoteData<Registration>) => { 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 { Injectable } from '@angular/core';
import { FeatureAuthorizationGuard } from './feature-authorization.guard'; import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { AuthorizationDataService } from '../authorization-data.service'; import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthService } from '../../../auth/auth.service'; import { AuthService } from '../../../auth/auth.service';
@@ -13,7 +13,7 @@ import { FeatureID } from '../feature-id';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class CollectionAdministratorGuard extends FeatureAuthorizationGuard { export class CollectionAdministratorGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService); super(authorizationService, router, authService);
} }

View File

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

View File

@@ -4,14 +4,14 @@ import { RemoteData } from '../../remote-data';
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { DSpaceObject } from '../../../shared/dspace-object.model'; 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 { FeatureID } from '../feature-id';
import { AuthService } from '../../../auth/auth.service'; 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>>, constructor(protected resolver: Resolve<RemoteData<any>>,
protected authorizationService: AuthorizationDataService, protected authorizationService: AuthorizationDataService,
protected router: Router, protected router: Router,
@@ -25,8 +25,8 @@ class DsoPageFeatureGuardImpl extends DsoPageFeatureGuard<any> {
} }
} }
describe('DsoPageAdministratorGuard', () => { describe('DsoPageSingleFeatureGuard', () => {
let guard: DsoPageFeatureGuard<any>; let guard: DsoPageSingleFeatureGuard<any>;
let authorizationService: AuthorizationDataService; let authorizationService: AuthorizationDataService;
let router: Router; let router: Router;
let authService: AuthService; let authService: AuthService;
@@ -62,7 +62,7 @@ describe('DsoPageAdministratorGuard', () => {
}, },
parent: parentRoute parent: parentRoute
}; };
guard = new DsoPageFeatureGuardImpl(resolver, authorizationService, router, authService, undefined); guard = new DsoPageSingleFeatureGuardImpl(resolver, authorizationService, router, authService, undefined);
} }
beforeEach(() => { 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 { getAllSucceededRemoteDataPayload } from '../../../shared/operators';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { DSpaceObject } from '../../../shared/dspace-object.model'; import { DSpaceObject } from '../../../shared/dspace-object.model';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { AuthService } from '../../../auth/auth.service'; import { AuthService } from '../../../auth/auth.service';
import { hasNoValue, hasValue } from '../../../../shared/empty.util'; 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 * 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>>, constructor(protected resolver: Resolve<RemoteData<T>>,
protected authorizationService: AuthorizationDataService, protected authorizationService: AuthorizationDataService,
protected router: Router, protected router: Router,

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'; 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 { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthService } from '../../../auth/auth.service'; import { AuthService } from '../../../auth/auth.service';
@@ -13,7 +13,7 @@ import { FeatureID } from '../feature-id';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class GroupAdministratorGuard extends FeatureAuthorizationGuard { export class GroupAdministratorGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, 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 { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id'; import { FeatureID } from '../feature-id';
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
@@ -6,10 +6,10 @@ import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/ro
import { AuthService } from '../../../auth/auth.service'; 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 * Provide the return values of the overwritten getters as constructor arguments
*/ */
class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard { class SingleFeatureAuthorizationGuardImpl extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, constructor(protected authorizationService: AuthorizationDataService,
protected router: Router, protected router: Router,
protected authService: AuthService, protected authService: AuthService,
@@ -32,8 +32,8 @@ class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard {
} }
} }
describe('FeatureAuthorizationGuard', () => { describe('SingleFeatureAuthorizationGuard', () => {
let guard: FeatureAuthorizationGuard; let guard: SingleFeatureAuthorizationGuard;
let authorizationService: AuthorizationDataService; let authorizationService: AuthorizationDataService;
let router: Router; let router: Router;
let authService: AuthService; let authService: AuthService;
@@ -56,7 +56,7 @@ describe('FeatureAuthorizationGuard', () => {
authService = jasmine.createSpyObj('authService', { authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true) isAuthenticated: observableOf(true)
}); });
guard = new FeatureAuthorizationGuardImpl(authorizationService, router, authService, featureId, objectUrl, ePersonUuid); guard = new SingleFeatureAuthorizationGuardImpl(authorizationService, router, authService, featureId, objectUrl, ePersonUuid);
} }
beforeEach(() => { 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 { Injectable } from '@angular/core';
import { FeatureAuthorizationGuard } from './feature-authorization.guard'; import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { FeatureID } from '../feature-id'; import { FeatureID } from '../feature-id';
import { AuthorizationDataService } from '../authorization-data.service'; import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
@@ -13,7 +13,7 @@ import { AuthService } from '../../../auth/auth.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class SiteAdministratorGuard extends FeatureAuthorizationGuard { export class SiteAdministratorGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, 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 { Injectable } from '@angular/core';
import { AuthorizationDataService } from '../authorization-data.service'; import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
@@ -13,7 +13,7 @@ import { AuthService } from '../../../auth/auth.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class SiteRegisterGuard extends FeatureAuthorizationGuard { export class SiteRegisterGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, 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 { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id'; import { FeatureID } from '../feature-id';
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; 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 { switchMap } from 'rxjs/operators';
import { AuthService } from '../../../auth/auth.service'; import { AuthService } from '../../../auth/auth.service';
/** /**
* Abstract Guard for preventing unauthorized activating and loading of routes when a user * 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. * 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 feature 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, constructor(protected authorizationService: AuthorizationDataService,
protected router: Router, protected router: Router,
protected authService: AuthService) { 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 * 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> { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return observableCombineLatest(this.getFeatureID(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe( return observableCombineLatest(this.getFeatureIDs(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe(
switchMap(([featureID, objectUrl, ePersonUuid]) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid)), switchMap(([featureIDs, objectUrl, ePersonUuid]) =>
returnForbiddenUrlTreeOrLoginOnFalse(this.router, this.authService, state.url) 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 * The features to check authorization for
* Override this method to define a feature * 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 * 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', IsCollectionAdmin = 'isCollectionAdmin',
IsCommunityAdmin = 'isCommunityAdmin', IsCommunityAdmin = 'isCommunityAdmin',
CanDownload = 'canDownload', 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 { 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 { followLink } from '../../shared/utils/follow-link-config.model';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; 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 { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
import { getMockRequestService } from '../../shared/mocks/request.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 service: RelationshipService;
let requestService: RequestService; let requestService: RequestService;
@@ -132,7 +131,8 @@ xdescribe('RelationshipService', () => {
null, null,
null, null,
null, null,
null null,
jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v),
); );
} }
@@ -195,8 +195,6 @@ xdescribe('RelationshipService', () => {
const rd$ = createSuccessfulRemoteDataObject$(relationsList); const rd$ = createSuccessfulRemoteDataObject$(relationsList);
spyOn(service, 'getItemRelationshipsByLabel').and.returnValue(rd$); spyOn(service, 'getItemRelationshipsByLabel').and.returnValue(rd$);
spyOnOperator(ItemRelationshipsUtils, 'paginatedRelationsToItems').and.returnValue((v) => v);
}); });
it('should call getItemRelationshipsByLabel with the correct params', (done) => { it('should call getItemRelationshipsByLabel with the correct params', (done) => {
@@ -225,7 +223,7 @@ xdescribe('RelationshipService', () => {
mockLabel, mockLabel,
mockOptions mockOptions
).subscribe((result) => { ).subscribe((result) => {
expect(ItemRelationshipsUtils.paginatedRelationsToItems).toHaveBeenCalledWith(mockItem.uuid); expect((service as any).paginatedRelationsToItems).toHaveBeenCalledWith(mockItem.uuid);
done(); done();
}); });
}); });

View File

@@ -1,11 +1,10 @@
import { HttpClient, HttpHeaders } from '@angular/common/http'; 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 { MemoizedSelector, select, Store } from '@ngrx/store';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators';
import { import {
compareArraysUsingIds, compareArraysUsingIds, PAGINATED_RELATIONS_TO_ITEMS_OPERATOR,
paginatedRelationsToItems,
relationsToItems relationsToItems
} from '../../+item-page/simple/item-types/shared/item-relationships-utils'; } from '../../+item-page/simple/item-types/shared/item-relationships-utils';
import { AppState, keySelector } from '../../app.reducer'; import { AppState, keySelector } from '../../app.reducer';
@@ -87,7 +86,8 @@ export class RelationshipService extends DataService<Relationship> {
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
protected http: HttpClient, protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<Relationship>, 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(); super();
} }
@@ -254,7 +254,7 @@ export class RelationshipService extends DataService<Relationship> {
* @param options * @param options
*/ */
getRelatedItemsByLabel(item: Item, label: string, options?: FindListOptions): Observable<RemoteData<PaginatedList<Item>>> { 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 { Router, UrlTree } from '@angular/router';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; 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 { hasNoValue, hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util';
import { SearchResult } from '../../shared/search/search-result.model'; import { SearchResult } from '../../shared/search/search-result.model';
import { PaginatedList } from '../data/paginated-list.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 { getForbiddenRoute, getPageNotFoundRoute } from '../../app-routing-paths';
import { getEndUserAgreementPath } from '../../info/info-routing-paths'; import { getEndUserAgreementPath } from '../../info/info-routing-paths';
import { AuthService } from '../auth/auth.service'; 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 * 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) => export const returnForbiddenUrlTreeOrLoginOnFalse = (router: Router, authService: AuthService, redirectUrl: string) =>
(source: Observable<boolean>): Observable<boolean | UrlTree> => (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( observableCombineLatest(source, authService.isAuthenticated()).pipe(
map(([authorized, authenticated]: [boolean, boolean]) => { map(([authorizedList, authenticated]: [boolean[], boolean]) => {
if (authorized) { if (authorizedList.some((b: boolean) => b === true)) {
return authorized; return true;
} else { } else {
if (authenticated) { if (authenticated) {
return router.parseUrl(getForbiddenRoute()); return router.parseUrl(getForbiddenRoute());

View File

@@ -23,7 +23,10 @@ describe('SearchConfigurationService', () => {
scope: '' 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', { const routeService = jasmine.createSpyObj('RouteService', {
getQueryParameterValue: observableOf(value1), getQueryParameterValue: observableOf(value1),

View File

@@ -1,8 +1,15 @@
import { Injectable, OnDestroy } from '@angular/core'; import { Injectable, OnDestroy } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router'; import { ActivatedRoute, Params } from '@angular/router';
import { BehaviorSubject, combineLatest as observableCombineLatest, merge as observableMerge, Observable, Subscription } from 'rxjs'; import {
import { filter, map, startWith } from 'rxjs/operators'; 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 { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SearchOptions } from '../../../shared/search/search-options.model'; import { SearchOptions } from '../../../shared/search/search-options.model';
import { PaginatedSearchOptions } from '../../../shared/search/paginated-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 { DSpaceObjectType } from '../dspace-object-type.model';
import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; import { SortDirection, SortOptions } from '../../cache/models/sort-options.model';
import { RouteService } from '../../services/route.service'; 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 { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; 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'; import { PaginationService } from '../../pagination/pagination.service';
/** /**
@@ -168,7 +181,7 @@ export class SearchConfigurationService implements OnDestroy {
if (hasNoValue(filters.find((f) => f.key === realKey))) { if (hasNoValue(filters.find((f) => f.key === realKey))) {
const min = filterParams[realKey + '.min'] ? filterParams[realKey + '.min'][0] : '*'; const min = filterParams[realKey + '.min'] ? filterParams[realKey + '.min'][0] : '*';
const max = filterParams[realKey + '.max'] ? filterParams[realKey + '.max'][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 { } else {
filters.push(new SearchFilter(key, filterParams[key])); filters.push(new SearchFilter(key, filterParams[key]));
@@ -194,6 +207,60 @@ export class SearchConfigurationService implements OnDestroy {
return this.routeService.getQueryParamsWithPrefix('f.'); 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 * 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 * @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); 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 { getSearchResultFor } from '../../../shared/search/search-result-element-decorator';
import { FacetConfigResponse } from '../../../shared/search/facet-config-response.model'; import { FacetConfigResponse } from '../../../shared/search/facet-config-response.model';
import { FacetValues } from '../../../shared/search/facet-values.model'; import { FacetValues } from '../../../shared/search/facet-values.model';
import { SearchConfig } from './search-filters/search-config.model';
import { PaginationService } from '../../pagination/pagination.service'; import { PaginationService } from '../../pagination/pagination.service';
import { SearchConfigurationService } from './search-configuration.service'; import { SearchConfigurationService } from './search-configuration.service';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
@@ -46,6 +47,12 @@ import { PaginationComponentOptions } from '../../../shared/pagination/paginatio
*/ */
@Injectable() @Injectable()
export class SearchService implements OnDestroy { export class SearchService implements OnDestroy {
/**
* Endpoint link path for retrieving search configurations
*/
private configurationLinkPath = 'discover/search';
/** /**
* Endpoint link path for retrieving general search results * Endpoint link path for retrieving general search results
*/ */
@@ -229,15 +236,7 @@ export class SearchService implements OnDestroy {
); );
} }
/** private getConfigUrl(url: string, scope?: string, configurationName?: string) {
* 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
* @param {string} configurationName the name of the configuration
* @returns {Observable<RemoteData<SearchFilterConfig[]>>} The found filter configuration
*/
getConfig(scope?: string, configurationName?: string): Observable<RemoteData<SearchFilterConfig[]>> {
const href$ = this.halService.getEndpoint(this.facetLinkPathPrefix).pipe(
map((url: string) => {
const args: string[] = []; const args: string[] = [];
if (isNotEmpty(scope)) { if (isNotEmpty(scope)) {
@@ -253,7 +252,17 @@ export class SearchService implements OnDestroy {
} }
return url; 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
* @param {string} configurationName the name of the configuration
* @returns {Observable<RemoteData<SearchFilterConfig[]>>} The found filter configuration
*/
getConfig(scope?: string, configurationName?: string): Observable<RemoteData<SearchFilterConfig[]>> {
const href$ = this.halService.getEndpoint(this.facetLinkPathPrefix).pipe(
map((url: string) => this.getConfigUrl(url, scope, configurationName)),
); );
href$.pipe(take(1)).subscribe((url: string) => { href$.pipe(take(1)).subscribe((url: string) => {
@@ -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 * @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 { CoreState } from '../../core/core.reducers';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPerson } from '../../core/eperson/models/eperson.model';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
@Component({ @Component({
selector: 'ds-forgot-password-form', selector: 'ds-forgot-password-form',
@@ -70,7 +71,9 @@ export class ForgotPasswordFormComponent {
*/ */
submit() { submit() {
if (!this.isInValid) { 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) { if (response.hasSucceeded) {
this.notificationsService.success( this.notificationsService.success(
this.translateService.instant(this.NOTIFICATIONS_PREFIX + '.success.title'), this.translateService.instant(this.NOTIFICATIONS_PREFIX + '.success.title'),

View File

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

View File

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

View File

@@ -21,8 +21,6 @@ import { createPaginatedList } from '../../../../testing/utils.test';
import { ExternalSourceService } from '../../../../../core/data/external-source.service'; import { ExternalSourceService } from '../../../../../core/data/external-source.service';
import { LookupRelationService } from '../../../../../core/data/lookup-relation.service'; import { LookupRelationService } from '../../../../../core/data/lookup-relation.service';
import { RemoteDataBuildService } from '../../../../../core/cache/builders/remote-data-build.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 { WorkspaceItem } from '../../../../../core/submission/models/workspaceitem.model';
import { Collection } from '../../../../../core/shared/collection.model'; import { Collection } from '../../../../../core/shared/collection.model';
@@ -46,8 +44,6 @@ describe('DsDynamicLookupRelationModalComponent', () => {
let lookupRelationService; let lookupRelationService;
let rdbService; let rdbService;
let submissionId; let submissionId;
let submissionService;
let submissionObjectDataService;
const externalSources = [ const externalSources = [
Object.assign(new ExternalSource(), { Object.assign(new ExternalSource(), {
@@ -99,12 +95,6 @@ describe('DsDynamicLookupRelationModalComponent', () => {
rdbService = jasmine.createSpyObj('rdbService', { rdbService = jasmine.createSpyObj('rdbService', {
aggregate: createSuccessfulRemoteDataObject$(externalSources) aggregate: createSuccessfulRemoteDataObject$(externalSources)
}); });
submissionService = jasmine.createSpyObj('SubmissionService', {
dispatchSave: jasmine.createSpy('dispatchSave')
});
submissionObjectDataService = jasmine.createSpyObj('SubmissionObjectDataService', {
findById: createSuccessfulRemoteDataObject$(testWSI)
});
submissionId = '1234'; submissionId = '1234';
} }
@@ -129,8 +119,6 @@ describe('DsDynamicLookupRelationModalComponent', () => {
}, },
{ provide: RelationshipTypeService, useValue: {} }, { provide: RelationshipTypeService, useValue: {} },
{ provide: RemoteDataBuildService, useValue: rdbService }, { provide: RemoteDataBuildService, useValue: rdbService },
{ provide: SubmissionService, useValue: submissionService },
{ provide: SubmissionObjectDataService, useValue: submissionObjectDataService },
{ {
provide: Store, useValue: { provide: Store, useValue: {
// tslint:disable-next-line:no-empty // 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 { SearchResult } from '../../../../search/search-result.model';
import { Item } from '../../../../../core/shared/item.model'; import { Item } from '../../../../../core/shared/item.model';
import { import {
getAllSucceededRemoteData, AddRelationshipAction, RemoveRelationshipAction, UpdateRelationshipNameVariantAction,
getAllSucceededRemoteDataPayload, } from './relationship.actions';
getRemoteDataPayload
} from '../../../../../core/shared/operators';
import { AddRelationshipAction, RemoveRelationshipAction, UpdateRelationshipNameVariantAction } from './relationship.actions';
import { RelationshipService } from '../../../../../core/data/relationship.service'; import { RelationshipService } from '../../../../../core/data/relationship.service';
import { RelationshipTypeService } from '../../../../../core/data/relationship-type.service'; import { RelationshipTypeService } from '../../../../../core/data/relationship-type.service';
import { Store } from '@ngrx/store'; 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 { ExternalSourceService } from '../../../../../core/data/external-source.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { RemoteDataBuildService } from '../../../../../core/cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../../../../core/cache/builders/remote-data-build.service';
import { followLink } from '../../../../utils/follow-link-config.model'; import { getAllSucceededRemoteDataPayload } from '../../../../../core/shared/operators';
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';
@Component({ @Component({
selector: 'ds-dynamic-lookup-relation-modal', selector: 'ds-dynamic-lookup-relation-modal',
@@ -122,10 +114,6 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
*/ */
totalExternal$: Observable<number[]>; totalExternal$: Observable<number[]>;
/**
* List of subscriptions to unsubscribe from
*/
private subs: Subscription[] = [];
constructor( constructor(
public modal: NgbActiveModal, public modal: NgbActiveModal,
@@ -136,17 +124,14 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
private lookupRelationService: LookupRelationService, private lookupRelationService: LookupRelationService,
private searchConfigService: SearchConfigurationService, private searchConfigService: SearchConfigurationService,
private rdbService: RemoteDataBuildService, private rdbService: RemoteDataBuildService,
private submissionService: SubmissionService,
private submissionObjectService: SubmissionObjectDataService,
private zone: NgZone, private zone: NgZone,
private store: Store<AppState>, private store: Store<AppState>,
private router: Router private router: Router,
) { ) {
} }
ngOnInit(): void { ngOnInit(): void {
this.setItem();
this.selection$ = this.selectableListService this.selection$ = this.selectableListService
.getSelectableList(this.listId) .getSelectableList(this.listId)
.pipe(map((listState: SelectableListState) => hasValue(listState) && hasValue(listState.selection) ? listState.selection : [])); .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 * Add a subscription updating relationships with name variants
* @param sri The search result to track name variants for * @param sri The search result to track name variants for
@@ -279,8 +246,5 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
ngOnDestroy() { ngOnDestroy() {
this.router.navigate([], {}); this.router.navigate([], {});
Object.values(this.subMap).forEach((subscription) => subscription.unsubscribe()); 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 { TestBed, waitForAsync } from '@angular/core/testing';
import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { provideMockActions } from '@ngrx/effects/testing'; import { provideMockActions } from '@ngrx/effects/testing';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { RelationshipEffects } from './relationship.effects'; import { RelationshipEffects } from './relationship.effects';
import { AddRelationshipAction, RelationshipActionTypes, RemoveRelationshipAction } from './relationship.actions'; import { AddRelationshipAction, RelationshipActionTypes, RemoveRelationshipAction } from './relationship.actions';
import { Item } from '../../../../../core/shared/item.model'; 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 { NotificationsService } from '../../../../notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service'; 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', () => { describe('RelationshipEffects', () => {
let relationEffects: RelationshipEffects; let relationEffects: RelationshipEffects;
@@ -51,7 +51,6 @@ describe('RelationshipEffects', () => {
let notificationsService; let notificationsService;
let translateService; let translateService;
let selectableListService; let selectableListService;
let testScheduler: TestScheduler;
function init() { function init() {
testUUID1 = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; testUUID1 = '20e24c2f-a00a-467c-bdee-c929e79bf08d';
@@ -131,6 +130,7 @@ describe('RelationshipEffects', () => {
{ provide: NotificationsService, useValue: notificationsService }, { provide: NotificationsService, useValue: notificationsService },
{ provide: TranslateService, useValue: translateService }, { provide: TranslateService, useValue: translateService },
{ provide: SelectableListService, useValue: selectableListService }, { 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); identifier = (relationEffects as any).createIdentifier(leftItem, rightItem, relationshipType.leftwardType);
spyOn((relationEffects as any), 'addRelationship').and.stub(); spyOn((relationEffects as any), 'addRelationship').and.stub();
spyOn((relationEffects as any), 'removeRelationship').and.stub(); spyOn((relationEffects as any), 'removeRelationship').and.stub();
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
}); });
describe('mapLastActions$', () => { describe('mapLastActions$', () => {
@@ -151,15 +148,13 @@ describe('RelationshipEffects', () => {
let action; let action;
it('should set the current value debounceMap and the value of the initialActionMap to ADD_RELATIONSHIP', () => { 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'); action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action }); actions = hot('--a-|', { a: action });
expectObservable(relationEffects.mapLastActions$).toBe('--b-|', { b: undefined }); const expected = cold('--b-|', { b: undefined });
flush(); expect(relationEffects.mapLastActions$).toBeObservable(expected);
// TODO check following expectations with the implementation
// expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type); expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type);
// expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type); expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
}); });
}); });
@@ -172,65 +167,59 @@ describe('RelationshipEffects', () => {
}); });
it('should set the current value debounceMap to ADD_RELATIONSHIP but not change the value of the initialActionMap', () => { 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'); action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action }); actions = hot('--a-|', { a: action });
expectObservable(relationEffects.mapLastActions$).toBe('--b-|', { b: undefined });
flush(); const expected = cold('--b-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType); expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType);
expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type); expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
}); });
}); });
});
describe('When the initialActionMap contains an ADD_RELATIONSHIP action', () => { describe('When the initialActionMap contains an ADD_RELATIONSHIP action', () => {
let action; let action;
describe('When the last value in the debounceMap is also an ADD_RELATIONSHIP action', () => { describe('When the last value in the debounceMap is also an ADD_RELATIONSHIP action', () => {
beforeEach(() => { beforeEach(() => {
(relationEffects as any).initialActionMap[identifier] = RelationshipActionTypes.ADD_RELATIONSHIP; (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', () => { it('should call addRelationship on the effect', () => {
testScheduler.run(({ hot, expectObservable, flush }) => {
action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234'); action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action }); actions = hot('--a-|', { a: action });
expectObservable(relationEffects.mapLastActions$).toBe('--b-|', { b: undefined }); const expected = cold('--b-|', { b: undefined });
flush(); expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).addRelationship).toHaveBeenCalledWith(leftItem, rightItem, relationshipType.leftwardType, '1234', undefined); 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', () => { 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', () => { 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 actiona = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
const actionb = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234'); const actionb = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--ab-|', { a: actiona, b: actionb }); actions = hot('--ab-|', { a: actiona, b: actionb });
expectObservable(relationEffects.mapLastActions$).toBe('--bb-|', { b: undefined }); const expected = cold('--bb-|', { b: undefined });
flush(); expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).addRelationship).not.toHaveBeenCalled(); expect((relationEffects as any).addRelationship).not.toHaveBeenCalled();
expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled(); expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled();
}); });
}); });
}); });
}); });
});
describe('When an REMOVE_RELATIONSHIP action is triggered', () => { describe('When an REMOVE_RELATIONSHIP action is triggered', () => {
describe('When it\'s the first time for this identifier', () => { describe('When it\'s the first time for this identifier', () => {
let action; let action;
it('should set the current value debounceMap and the value of the initialActionMap to REMOVE_RELATIONSHIP', () => { 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'); action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action }); actions = hot('--a-|', { a: action });
expectObservable(relationEffects.mapLastActions$).toBe('--b-|', { b: undefined }); const expected = cold('--b-|', { b: undefined });
flush(); expect(relationEffects.mapLastActions$).toBeObservable(expected);
// TODO check following expectations with the implementation
// expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type); expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type);
// expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type); expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
}); });
}); });
@@ -238,20 +227,19 @@ describe('RelationshipEffects', () => {
let action; let action;
const testActionType = 'TEST_TYPE'; const testActionType = 'TEST_TYPE';
beforeEach(() => { beforeEach(() => {
((relationEffects as any).debounceTime as jasmine.Spy).and.returnValue((v) => v);
(relationEffects as any).initialActionMap[identifier] = testActionType; (relationEffects as any).initialActionMap[identifier] = testActionType;
(relationEffects as any).debounceMap[identifier] = new BehaviorSubject<string>(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', () => { 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'); action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action }); actions = hot('--a-|', { a: action });
expectObservable(relationEffects.mapLastActions$).toBe('--b-|', { b: undefined }); const expected = cold('--b-|', { b: undefined });
flush(); expect(relationEffects.mapLastActions$).toBeObservable(expected);
// TODO check following expectations with the implementation
// expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType); expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType);
// expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type); expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
}); });
}); });
@@ -259,29 +247,26 @@ describe('RelationshipEffects', () => {
let action; let action;
describe('When the last value in the debounceMap is also an REMOVE_RELATIONSHIP action', () => { describe('When the last value in the debounceMap is also an REMOVE_RELATIONSHIP action', () => {
beforeEach(() => { beforeEach(() => {
((relationEffects as any).debounceTime as jasmine.Spy).and.returnValue((v) => v);
(relationEffects as any).initialActionMap[identifier] = RelationshipActionTypes.REMOVE_RELATIONSHIP; (relationEffects as any).initialActionMap[identifier] = RelationshipActionTypes.REMOVE_RELATIONSHIP;
}); });
it('should call removeRelationship on the effect', () => { it('should call removeRelationship on the effect', () => {
testScheduler.run(({ hot, expectObservable, flush }) => {
action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234'); action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('a', { a: action }); actions = hot('--a-|', { a: action });
expectObservable(relationEffects.mapLastActions$).toBe('b', { b: undefined }); const expected = cold('--b-|', { b: undefined });
flush(); expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).removeRelationship).toHaveBeenCalledWith(leftItem, rightItem, relationshipType.leftwardType, '1234',); 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', () => { 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', () => { 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 actionb = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
const actiona = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234'); const actiona = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--ab-|', { a: actiona, b: actionb }); actions = hot('--ab-|', { a: actiona, b: actionb });
expectObservable(relationEffects.mapLastActions$).toBe('--bb-|', { b: undefined }); const expected = cold('--bb-|', { b: undefined });
flush(); expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).addRelationship).not.toHaveBeenCalled(); expect((relationEffects as any).addRelationship).not.toHaveBeenCalled();
expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled(); expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled();
}); });
@@ -290,4 +275,3 @@ describe('RelationshipEffects', () => {
}); });
}); });
}); });
});

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 { 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 { BehaviorSubject, Observable } from 'rxjs';
import { RelationshipService } from '../../../../../core/data/relationship.service'; import { RelationshipService } from '../../../../../core/data/relationship.service';
import { import {
getRemoteDataPayload, getRemoteDataPayload,
getFirstSucceededRemoteData getFirstSucceededRemoteData, DEBOUNCE_TIME_OPERATOR
} from '../../../../../core/shared/operators'; } from '../../../../../core/shared/operators';
import { import {
AddRelationshipAction, AddRelationshipAction,
@@ -71,7 +71,7 @@ export class RelationshipEffects {
this.initialActionMap[identifier] = action.type; this.initialActionMap[identifier] = action.type;
this.debounceMap[identifier] = new BehaviorSubject<string>(action.type); this.debounceMap[identifier] = new BehaviorSubject<string>(action.type);
this.debounceMap[identifier].pipe( this.debounceMap[identifier].pipe(
debounceTime(DEBOUNCE_TIME), this.debounceTime(DEBOUNCE_TIME),
take(1) take(1)
).subscribe( ).subscribe(
(type) => { (type) => {
@@ -159,6 +159,7 @@ export class RelationshipEffects {
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private translateService: TranslateService, private translateService: TranslateService,
private selectableListService: SelectableListService, 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 { 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 { Context } from '../../core/shared/context.model';
import { import {
MetadataRepresentation, MetadataRepresentation,
MetadataRepresentationType MetadataRepresentationType
} from '../../core/shared/metadata-representation/metadata-representation.model'; } from '../../core/shared/metadata-representation/metadata-representation.model';
import { MetadataRepresentationLoaderComponent } from './metadata-representation-loader.component'; 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 { 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 testType = 'TestType';
const testContext = Context.Search; const testContext = Context.Search;
@@ -29,16 +29,30 @@ class TestType implements MetadataRepresentation {
} }
} }
xdescribe('MetadataRepresentationLoaderComponent', () => { describe('MetadataRepresentationLoaderComponent', () => {
let comp: MetadataRepresentationLoaderComponent; let comp: MetadataRepresentationLoaderComponent;
let fixture: ComponentFixture<MetadataRepresentationLoaderComponent>; let fixture: ComponentFixture<MetadataRepresentationLoaderComponent>;
let themeService: ThemeService;
const themeName = 'test-theme';
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
themeService = jasmine.createSpyObj('themeService', {
getThemeName: themeName,
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [], imports: [],
declarations: [MetadataRepresentationLoaderComponent, PlainTextMetadataListElementComponent, MetadataRepresentationDirective], declarations: [MetadataRepresentationLoaderComponent, PlainTextMetadataListElementComponent, MetadataRepresentationDirective],
schemas: [NO_ERRORS_SCHEMA], 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, { }).overrideComponent(MetadataRepresentationLoaderComponent, {
set: { set: {
changeDetection: ChangeDetectionStrategy.Default, changeDetection: ChangeDetectionStrategy.Default,
@@ -53,15 +67,12 @@ xdescribe('MetadataRepresentationLoaderComponent', () => {
comp.mdRepresentation = new TestType(); comp.mdRepresentation = new TestType();
comp.context = testContext; comp.context = testContext;
spyOnExported(metadataRepresentationDecorator, 'getMetadataRepresentationComponent').and.returnValue(PlainTextMetadataListElementComponent);
fixture.detectChanges(); fixture.detectChanges();
})); }));
describe('When the component is rendered', () => { describe('When the component is rendered', () => {
it('should call the getMetadataRepresentationComponent function with the right entity type, representation type and context', () => { 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 { Component, ComponentFactoryResolver, Inject, Input, OnInit, ViewChild } from '@angular/core';
import { MetadataRepresentation } from '../../core/shared/metadata-representation/metadata-representation.model'; import {
import { getMetadataRepresentationComponent } from './metadata-representation.decorator'; 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 { Context } from '../../core/shared/context.model';
import { GenericConstructor } from '../../core/shared/generic-constructor'; import { GenericConstructor } from '../../core/shared/generic-constructor';
import { MetadataRepresentationListElementComponent } from '../object-list/metadata-representation-list-element/metadata-representation-list-element.component'; import { MetadataRepresentationListElementComponent } from '../object-list/metadata-representation-list-element/metadata-representation-list-element.component';
@@ -45,7 +48,8 @@ export class MetadataRepresentationLoaderComponent implements OnInit {
constructor( constructor(
private componentFactoryResolver: ComponentFactoryResolver, 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} * @returns {string}
*/ */
private getComponent(): GenericConstructor<MetadataRepresentationListElementComponent> { 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 { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model';
import { hasNoValue, hasValue } from '../empty.util'; import { hasNoValue, hasValue } from '../empty.util';
import { Context } from '../../core/shared/context.model'; 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(); 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 * the mock response if there is one, undefined otherwise
*/ */
private getMockData(urlStr: string): any { private getMockData(urlStr: string): any {
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); const url = new URL(urlStr);
const key = url.pathname.slice(environment.rest.nameSpace.length); key = url.pathname.slice(environment.rest.nameSpace.length);
}
if (this.mockResponseMap.has(key)) { if (this.mockResponseMap.has(key)) {
// parse and stringify to clone the object to ensure that any changes made // parse and stringify to clone the object to ensure that any changes made
// to it afterwards don't affect future calls // 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 mockSubmissionResponse from './mock-submission-response.json';
// import mockPublicationResponse from './mock-publication-response.json'; // import mockPublicationResponse from './mock-publication-response.json';
// import mockUntypedItemResponse from './mock-untyped-item-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> {} export class ResponseMapMock extends Map<string, any> {}
@@ -16,4 +20,8 @@ export const mockResponseMap: ResponseMapMock = new Map([
// [ '/config/submissionforms/traditionalpageone', mockSubmissionResponse ] // [ '/config/submissionforms/traditionalpageone', mockSubmissionResponse ]
// [ '/api/pid/find', mockPublicationResponse ], // [ '/api/pid/find', mockPublicationResponse ],
// [ '/api/pid/find', mockUntypedItemResponse ], // [ '/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; let options: PaginatedSearchOptions;
const sortOptions = new SortOptions('test.field', SortDirection.DESC); const sortOptions = new SortOptions('test.field', SortDirection.DESC);
const pageOptions = Object.assign(new PaginationComponentOptions(), { pageSize: 40, page: 1 }); 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 query = 'search query';
const scope = '0fde1ecb-82cc-425a-b600-ac3576d76b47'; const scope = '0fde1ecb-82cc-425a-b600-ac3576d76b47';
const baseUrl = 'www.rest.com'; const baseUrl = 'www.rest.com';
@@ -19,7 +24,8 @@ describe('PaginatedSearchOptions', () => {
filters: filters, filters: filters,
query: query, query: query,
scope: scope, scope: scope,
dsoTypes: [DSpaceObjectType.ITEM] dsoTypes: [DSpaceObjectType.ITEM],
fixedFilter: fixedFilter,
}); });
}); });
@@ -31,12 +37,14 @@ describe('PaginatedSearchOptions', () => {
'sort=test.field,DESC&' + 'sort=test.field,DESC&' +
'page=0&' + 'page=0&' +
'size=40&' + 'size=40&' +
'query=search query&' + 'f.fixed=1234%2C5678,equals&' +
'query=search%20query&' +
'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' + 'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' +
'dsoType=ITEM&' + 'dsoType=ITEM&' +
'f.test=value&' + 'f.test=value&' +
'f.example=another value&' + 'f.example=another%20value&' +
'f.example=second value' '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 * 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 * Emits true when the filter is currently collapsed in the store

View File

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

View File

@@ -56,7 +56,7 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
/** /**
* Fallback maximum for the range * Fallback maximum for the range
*/ */
max = 2018; max = new Date().getFullYear();
/** /**
* The current range of the filter * 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'; import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
describe('SearchOptions', () => { describe('SearchOptions', () => {
let options: PaginatedSearchOptions; let options: SearchOptions;
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 query = 'search query';
const scope = '0fde1ecb-82cc-425a-b600-ac3576d76b47'; const scope = '0fde1ecb-82cc-425a-b600-ac3576d76b47';
const baseUrl = 'www.rest.com'; const baseUrl = 'www.rest.com';
beforeEach(() => { 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', () => { describe('when toRestUrl is called', () => {
@@ -18,12 +30,14 @@ describe('SearchOptions', () => {
it('should generate a string with all parameters that are present', () => { it('should generate a string with all parameters that are present', () => {
const outcome = options.toRestUrl(baseUrl); const outcome = options.toRestUrl(baseUrl);
expect(outcome).toEqual('www.rest.com?' + expect(outcome).toEqual('www.rest.com?' +
'query=search query&' + 'f.fixed=1234%2C5678,equals&' +
'query=search%20query&' +
'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' + 'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' +
'dsoType=ITEM&' + 'dsoType=ITEM&' +
'f.test=value&' + 'f.test=value&' +
'f.example=another value&' + 'f.example=another%20value&' +
'f.example=second value' '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 { URLCombiner } from '../../core/url-combiner/url-combiner';
import { SearchFilter } from './search-filter.model'; import { SearchFilter } from './search-filter.model';
import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model'; import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
@@ -13,10 +13,15 @@ export class SearchOptions {
scope?: string; scope?: string;
query?: string; query?: string;
dsoTypes?: DSpaceObjectType[]; dsoTypes?: DSpaceObjectType[];
filters?: any; filters?: SearchFilter[];
fixedFilter?: any; 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.configuration = options.configuration;
this.scope = options.scope; this.scope = options.scope;
this.query = options.query; this.query = options.query;
@@ -33,27 +38,27 @@ export class SearchOptions {
*/ */
toRestUrl(url: string, args: string[] = []): string { toRestUrl(url: string, args: string[] = []): string {
if (isNotEmpty(this.configuration)) { if (isNotEmpty(this.configuration)) {
args.push(`configuration=${this.configuration}`); args.push(`configuration=${encodeURIComponent(this.configuration)}`);
} }
if (isNotEmpty(this.fixedFilter)) { if (isNotEmpty(this.fixedFilter)) {
args.push(this.fixedFilter); args.push(this.encodedFixedFilter);
} }
if (isNotEmpty(this.query)) { if (isNotEmpty(this.query)) {
args.push(`query=${this.query}`); args.push(`query=${encodeURIComponent(this.query)}`);
} }
if (isNotEmpty(this.scope)) { if (isNotEmpty(this.scope)) {
args.push(`scope=${this.scope}`); args.push(`scope=${encodeURIComponent(this.scope)}`);
} }
if (isNotEmpty(this.dsoTypes)) { if (isNotEmpty(this.dsoTypes)) {
this.dsoTypes.forEach((dsoType: string) => { this.dsoTypes.forEach((dsoType: string) => {
args.push(`dsoType=${dsoType}`); args.push(`dsoType=${encodeURIComponent(dsoType)}`);
}); });
} }
if (isNotEmpty(this.filters)) { if (isNotEmpty(this.filters)) {
this.filters.forEach((filter: SearchFilter) => { this.filters.forEach((filter: SearchFilter) => {
filter.values.forEach((value) => { filter.values.forEach((value) => {
const filterValue = value.includes(',') ? `${value}` : value + (filter.operator ? ',' + filter.operator : ''); 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; 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> <h3>{{ 'search.sidebar.settings.title' | translate}}</h3>
<div class="result-order-settings"> <div class="result-order-settings">
<ds-sidebar-dropdown <ds-sidebar-dropdown
@@ -7,7 +7,7 @@
[label]="'search.sidebar.settings.sort-by'" [label]="'search.sidebar.settings.sort-by'"
(change)="reloadOrder($event)" (change)="reloadOrder($event)"
> >
<option *ngFor="let sortOption of searchOptionPossibilities" <option *ngFor="let sortOption of sortOptions"
[value]="sortOption.field + ',' + sortOption.direction.toString()" [value]="sortOption.field + ',' + sortOption.direction.toString()"
[selected]="sortOption.field === config?.sort.field && sortOption.direction === (config?.sort.direction)? 'selected': null"> [selected]="sortOption.field === config?.sort.field && sortOption.direction === (config?.sort.direction)? 'selected': null">
{{'sorting.' + sortOption.field + '.' + sortOption.direction | translate}} {{'sorting.' + sortOption.field + '.' + sortOption.direction | translate}}

View File

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

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 { SearchService } from '../../../core/shared/search/search.service';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { PaginatedSearchOptions } from '../paginated-search-options.model'; import { PaginatedSearchOptions } from '../paginated-search-options.model';
import { Observable } from 'rxjs';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component';
import { PaginationService } from '../../../core/pagination/pagination.service'; 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. * 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 * The configuration for the current paginated search results
*/ */
searchOptions$: Observable<PaginatedSearchOptions>; @Input() searchOptions: PaginatedSearchOptions;
/** /**
* All sort options that are shown in the settings * 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, constructor(private service: SearchService,
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -35,13 +35,6 @@ export class SearchSettingsComponent implements OnInit {
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigurationService: SearchConfigurationService) { @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 * Method to change the current sort field and direction
* @param {Event} event Change event containing the sort direction and sort field * @param {Event} event Change event containing the sort direction and sort field

View File

@@ -12,7 +12,7 @@
<div class="sidebar-content"> <div class="sidebar-content">
<ds-search-switch-configuration [inPlaceSearch]="inPlaceSearch" *ngIf="configurationList" [configurationList]="configurationList"></ds-search-switch-configuration> <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-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> </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 { SearchConfigurationOption } from '../search-switch-configuration/search-configuration-option.model';
import { Observable } from 'rxjs'; 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. * This component renders a simple item page.
@@ -45,6 +47,16 @@ export class SearchSidebarComponent {
*/ */
@Input() inPlaceSearch; @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. * 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.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", "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.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.button": "Withdraw...",
"item.edit.tabs.status.buttons.withdraw.label": "Withdraw item from the repository", "item.edit.tabs.status.buttons.withdraw.label": "Withdraw item from the repository",
@@ -3030,6 +3032,8 @@
"search.results.empty": "Your search returned no results.", "search.results.empty": "Your search returned no results.",
"default.search.results.head": "Search Results",
"search.sidebar.close": "Back to results", "search.sidebar.close": "Back to results",
@@ -3063,8 +3067,21 @@
"sorting.dc.title.DESC": "Title Descending", "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", "statistics.title": "Statistics",

View File

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