Merge remote-tracking branch 'remotes/upstreamatmire/Search-optimizations' into mydspace

# Conflicts:
#	resources/i18n/en.json
#	src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts
#	src/app/+search-page/search-filters/search-filter/search-filter.component.ts
#	src/app/+search-page/search-filters/search-filters.component.html
#	src/app/+search-page/search-filters/search-filters.component.ts
#	src/app/+search-page/search-page.component.ts
#	src/app/+search-page/search-page.module.ts
#	src/app/+search-page/search-service/search.service.ts
#	src/app/core/cache/builders/remote-data-build.service.ts
#	src/app/core/core.module.ts
#	src/app/core/data/paginated-list.ts
#	src/app/core/data/request.models.ts
#	src/app/core/data/request.service.spec.ts
#	src/app/core/data/request.service.ts
#	src/app/shared/services/route.service.ts
#	src/app/shared/shared.module.ts
This commit is contained in:
Giuseppe Digilio
2019-03-26 10:01:57 +01:00
93 changed files with 2805 additions and 730 deletions

View File

@@ -120,6 +120,7 @@
"pem": "1.12.3",
"reflect-metadata": "0.1.12",
"rxjs": "6.2.2",
"rxjs-spy": "^7.5.1",
"sortablejs": "1.7.0",
"text-mask-core": "5.0.1",
"ts-loader": "^5.2.1",

View File

@@ -894,5 +894,33 @@
"browse": "browse",
"queue-lenght": "Queue length",
"processing": "Processing"
},
"dso-selector": {
"create": {
"community": {
"head": "New community",
"sub-level": "Create a new community in",
"top-level": "Create a new top-level community"
},
"collection": {
"head": "New collection"
},
"item": {
"head": "New item"
}
},
"edit": {
"community": {
"head": "Edit community"
},
"collection": {
"head": "Edit collection"
},
"item": {
"head": "Edit item"
}
},
"placeholder": "Search for a {{ type }}",
"no-results": "No {{ type }} found"
}
}

View File

@@ -12,6 +12,7 @@ import { AuthService } from '../../core/auth/auth.service';
import { of as observableOf } from 'rxjs';
import { By } from '@angular/platform-browser';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
describe('AdminSidebarComponent', () => {
let comp: AdminSidebarComponent;
@@ -26,7 +27,12 @@ describe('AdminSidebarComponent', () => {
{ provide: Injector, useValue: {} },
{ provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: AuthService, useClass: AuthServiceStub }
{ provide: AuthService, useClass: AuthServiceStub },
{
provide: NgbModal, useValue: {
open: () => {/*comment*/}
}
}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(AdminSidebarComponent, {
@@ -96,7 +102,10 @@ describe('AdminSidebarComponent', () => {
beforeEach(() => {
spyOn(menuService, 'toggleMenu');
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('a.shortcut-icon'));
sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}});
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
});
it('should call toggleMenu on the menuService', () => {
@@ -108,7 +117,10 @@ describe('AdminSidebarComponent', () => {
beforeEach(() => {
spyOn(menuService, 'toggleMenu');
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('.sidebar-collapsible')).query(By.css('a'));
sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}});
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
});
it('should call toggleMenu on the menuService', () => {
@@ -120,7 +132,10 @@ describe('AdminSidebarComponent', () => {
it('should call expandPreview on the menuService after 100ms', fakeAsync(() => {
spyOn(menuService, 'expandMenuPreview');
const sidebarToggler = fixture.debugElement.query(By.css('nav.navbar'));
sidebarToggler.triggerEventHandler('mouseenter', {preventDefault: () => {/**/}});
sidebarToggler.triggerEventHandler('mouseenter', {
preventDefault: () => {/**/
}
});
tick(99);
expect(menuService.expandMenuPreview).not.toHaveBeenCalled();
tick(1);
@@ -132,7 +147,10 @@ describe('AdminSidebarComponent', () => {
it('should call collapseMenuPreview on the menuService after 400ms', fakeAsync(() => {
spyOn(menuService, 'collapseMenuPreview');
const sidebarToggler = fixture.debugElement.query(By.css('nav.navbar'));
sidebarToggler.triggerEventHandler('mouseleave', {preventDefault: () => {/**/}});
sidebarToggler.triggerEventHandler('mouseleave', {
preventDefault: () => {/**/
}
});
tick(399);
expect(menuService.collapseMenuPreview).not.toHaveBeenCalled();
tick(1);

View File

@@ -1,6 +1,6 @@
import { Component, Injector, OnInit } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { slide, slideHorizontal, slideSidebar } from '../../shared/animations/slide';
import { slideHorizontal, slideSidebar } from '../../shared/animations/slide';
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service';
import { MenuService } from '../../shared/menu/menu.service';
import { MenuID, MenuItemType } from '../../shared/menu/initial-menus-state';
@@ -10,6 +10,14 @@ import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model
import { AuthService } from '../../core/auth/auth.service';
import { first, map } from 'rxjs/operators';
import { combineLatest as combineLatestObservable } from 'rxjs';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model';
import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component';
import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component';
import { CreateCollectionParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component';
import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component';
import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component';
import { EditCollectionSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component';
/**
* Component representing the admin sidebar
@@ -52,7 +60,8 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
constructor(protected menuService: MenuService,
protected injector: Injector,
private variableService: CSSVariableService,
private authService: AuthService
private authService: AuthService,
private modalService: NgbModal
) {
super(menuService, injector);
}
@@ -104,10 +113,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
type: MenuItemType.ONCLICK,
text: 'menu.section.new_community',
link: '/communities/submission'
} as LinkMenuItemModel,
function: () => {
this.modalService.open(CreateCommunityParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_collection',
@@ -115,10 +126,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
type: MenuItemType.ONCLICK,
text: 'menu.section.new_collection',
link: '/collections/submission'
} as LinkMenuItemModel,
function: () => {
this.modalService.open(CreateCollectionParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_item',
@@ -126,10 +139,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
type: MenuItemType.ONCLICK,
text: 'menu.section.new_item',
link: '/items/submission'
} as LinkMenuItemModel,
function: () => {
this.modalService.open(CreateItemParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_item_version',
@@ -161,10 +176,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_community',
link: '#'
} as LinkMenuItemModel,
function: () => {
this.modalService.open(EditCommunitySelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_collection',
@@ -172,10 +189,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_collection',
link: '#'
} as LinkMenuItemModel,
function: () => {
this.modalService.open(EditCollectionSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_item',
@@ -183,10 +202,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_item',
link: '#'
} as LinkMenuItemModel,
function: () => {
this.modalService.open(EditItemSelectorComponent);
}
} as OnClickMenuItemModel,
},
/* Import */
@@ -223,7 +244,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
link: '#'
} as LinkMenuItemModel,
},
/* Export */
{
id: 'export',

View File

@@ -1,12 +1,14 @@
import { NgModule } from '@angular/core';
import { AdminRegistriesModule } from './admin-registries/admin-registries.module';
import { AdminRoutingModule } from './admin-routing.module';
import { SharedModule } from '../shared/shared.module';
@NgModule({
imports: [
AdminRegistriesModule,
AdminRoutingModule,
]
SharedModule,
],
})
export class AdminModule {

View File

@@ -8,17 +8,36 @@ import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component';
import { CreateCollectionPageGuard } from './create-collection-page/create-collection-page.guard';
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getCollectionModulePath } from '../app-routing.module';
export const COLLECTION_PARENT_PARAMETER = 'parent';
export function getCollectionPageRoute(collectionId: string) {
return new URLCombiner(getCollectionModulePath(), collectionId).toString();
}
export function getCollectionEditPath(id: string) {
return new URLCombiner(getCollectionModulePath(), COLLECTION_EDIT_PATH.replace(/:id/, id)).toString()
}
export function getCollectionCreatePath() {
return new URLCombiner(getCollectionModulePath(), COLLECTION_CREATE_PATH).toString()
}
const COLLECTION_CREATE_PATH = 'create';
const COLLECTION_EDIT_PATH = ':id/edit';
@NgModule({
imports: [
RouterModule.forChild([
{
path: 'create',
path: COLLECTION_CREATE_PATH,
component: CreateCollectionPageComponent,
canActivate: [AuthenticatedGuard, CreateCollectionPageGuard]
},
{
path: ':id/edit',
path: COLLECTION_EDIT_PATH,
pathMatch: 'full',
component: EditCollectionPageComponent,
canActivate: [AuthenticatedGuard],

View File

@@ -15,7 +15,6 @@ import { DeleteCollectionPageComponent } from './delete-collection-page/delete-c
imports: [
CommonModule,
SharedModule,
SearchPageModule,
CollectionPageRoutingModule
],
declarations: [

View File

@@ -8,17 +8,36 @@ import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { EditCommunityPageComponent } from './edit-community-page/edit-community-page.component';
import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard';
import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component';
import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getCommunityModulePath } from '../app-routing.module';
export const COMMUNITY_PARENT_PARAMETER = 'parent';
export function getCommunityPageRoute(communityId: string) {
return new URLCombiner(getCommunityModulePath(), communityId).toString();
}
export function getCommunityEditPath(id: string) {
return new URLCombiner(getCommunityModulePath(), COMMUNITY_EDIT_PATH.replace(/:id/, id)).toString()
}
export function getCommunityCreatePath() {
return new URLCombiner(getCommunityModulePath(), COMMUNITY_CREATE_PATH).toString()
}
const COMMUNITY_CREATE_PATH = 'create';
const COMMUNITY_EDIT_PATH = ':id/edit';
@NgModule({
imports: [
RouterModule.forChild([
{
path: 'create',
path: COMMUNITY_CREATE_PATH,
component: CreateCommunityPageComponent,
canActivate: [AuthenticatedGuard, CreateCommunityPageGuard]
},
{
path: ':id/edit',
path: COMMUNITY_EDIT_PATH,
pathMatch: 'full',
component: EditCommunityPageComponent,
canActivate: [AuthenticatedGuard],

View File

@@ -1,24 +1,9 @@
<div>
<div class="filters py-2">
<a *ngFor="let value of (selectedValues | async)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getRemoveParams(value) | async" queryParamsHandling="merge">
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
<span class="filter-value pl-1">{{value}}</span>
</a>
<ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$"></ds-search-facet-selected-option>
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
<div [@facetLoad]="animationState">
<ng-container *ngFor="let value of page.page; let i=index">
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getAddParams(value.value) | async" queryParamsHandling="merge">
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
<span class="filter-value px-1">{{value.value}}</span>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
</span>
</a>
</ng-container>
<ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$"></ds-search-facet-option>
</div>
</ng-container>
<div class="clearfix toggle-more-filters">

View File

@@ -0,0 +1,9 @@
<a *ngIf="isVisible | async" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="addQueryParams" queryParamsHandling="merge">
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
<span class="filter-value px-1">{{filterValue.value}}</span>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{filterValue.count}}</span>
</span>
</a>

View File

@@ -0,0 +1,121 @@
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { SearchFacetOptionComponent } from './search-facet-option.component';
import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model';
import { FilterType } from '../../../../search-service/filter-type.model';
import { FacetValue } from '../../../../search-service/facet-value.model';
import { FormsModule } from '@angular/forms';
import { of as observableOf } from 'rxjs';
import { SearchService } from '../../../../search-service/search.service';
import { SearchServiceStub } from '../../../../../shared/testing/search-service-stub';
import { Router } from '@angular/router';
import { RouterStub } from '../../../../../shared/testing/router-stub';
import { SearchConfigurationService } from '../../../../search-service/search-configuration.service';
import { SearchFilterService } from '../../search-filter.service';
import { By } from '@angular/platform-browser';
describe('SearchFacetOptionComponent', () => {
let comp: SearchFacetOptionComponent;
let fixture: ComponentFixture<SearchFacetOptionComponent>;
const filterName1 = 'test name';
const value1 = 'testvalue1';
const value2 = 'test2';
const value3 = 'another value3';
const mockFilterConfig = Object.assign(new SearchFilterConfig(), {
name: filterName1,
type: FilterType.range,
hasFacets: false,
isOpenByDefault: false,
pageSize: 2,
minValue: 200,
maxValue: 3000,
});
const value: FacetValue = {
value: value2,
count: 20,
search: ''
};
const searchLink = '/search';
const selectedValues = [value1];
const selectedValues$ = observableOf(selectedValues);
let filterService;
let searchService;
let router;
const page = observableOf(0);
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
declarations: [SearchFacetOptionComponent],
providers: [
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
{ provide: Router, useValue: new RouterStub() },
{
provide: SearchConfigurationService, useValue: {
searchOptions: observableOf({})
}
},
{
provide: SearchFilterService, useValue: {
getSelectedValuesForFilter: () => selectedValues,
isFilterActiveWithValue: (paramName: string, filterValue: string) => observableOf(true),
getPage: (paramName: string) => page,
/* tslint:disable:no-empty */
incrementPage: (filterName: string) => {
},
resetPage: (filterName: string) => {
}
/* tslint:enable:no-empty */
}
}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFacetOptionComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchFacetOptionComponent);
comp = fixture.componentInstance; // SearchPageComponent test instance
filterService = (comp as any).filterService;
searchService = (comp as any).searchService;
router = (comp as any).router;
comp.filterValue = value;
comp.selectedValues$ = selectedValues$;
comp.filterConfig = mockFilterConfig;
fixture.detectChanges();
});
describe('when the updateAddParams method is called wih a value', () => {
it('should update the addQueryParams with the new parameter values', () => {
comp.addQueryParams = {};
(comp as any).updateAddParams(selectedValues);
expect(comp.addQueryParams).toEqual({
[mockFilterConfig.paramName]: [value1, value.value],
page: 1
});
});
});
describe('when isVisible emits true', () => {
it('the facet option should be visible', () => {
comp.isVisible = observableOf(true);
fixture.detectChanges();
const linkEl = fixture.debugElement.query(By.css('a'));
expect(linkEl).not.toBeNull();
});
});
describe('when isVisible emits false', () => {
it('the facet option should not be visible', () => {
comp.isVisible = observableOf(false);
fixture.detectChanges();
const linkEl = fixture.debugElement.query(By.css('a'));
expect(linkEl).toBeNull();
});
});
});

View File

@@ -0,0 +1,102 @@
import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { FacetValue } from '../../../../search-service/facet-value.model';
import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model';
import { SearchService } from '../../../../search-service/search.service';
import { SearchFilterService } from '../../search-filter.service';
import { SearchConfigurationService } from '../../../../search-service/search-configuration.service';
import { hasValue } from '../../../../../shared/empty.util';
@Component({
selector: 'ds-search-facet-option',
templateUrl: './search-facet-option.component.html',
})
/**
* Represents a single option in a filter facet
*/
export class SearchFacetOptionComponent implements OnInit, OnDestroy {
/**
* A single value for this component
*/
@Input() filterValue: FacetValue;
/**
* The filter configuration for this facet option
*/
@Input() filterConfig: SearchFilterConfig;
/**
* Emits the active values for this filter
*/
@Input() selectedValues$: Observable<string[]>;
/**
* Emits true when this option should be visible and false when it should be invisible
*/
isVisible: Observable<boolean>;
/**
* UI parameters when this filter is added
*/
addQueryParams;
/**
* Subscription to unsubscribe from on destroy
*/
sub: Subscription;
constructor(protected searchService: SearchService,
protected filterService: SearchFilterService,
protected searchConfigService: SearchConfigurationService,
protected router: Router
) {
}
/**
* Initializes all observable instance variables and starts listening to them
*/
ngOnInit(): void {
this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked));
this.sub = observableCombineLatest(this.selectedValues$, this.searchConfigService.searchOptions)
.subscribe(([selectedValues, searchOptions]) => {
this.updateAddParams(selectedValues)
});
}
/**
* Checks if a value for this filter is currently active
*/
private isChecked(): Observable<boolean> {
return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, this.filterValue.value);
}
/**
* @returns {string} The base path to the search page
*/
getSearchLink() {
return this.searchService.getSearchLink();
}
/**
* Calculates the parameters that should change if a given value for this filter would be added to the active filters
* @param {string[]} selectedValues The values that are currently selected for this filter
*/
private updateAddParams(selectedValues: string[]): void {
this.addQueryParams = {
[this.filterConfig.paramName]: [...selectedValues, this.filterValue.value],
page: 1
};
}
/**
* Make sure the subscription is unsubscribed from when this component is destroyed
*/
ngOnDestroy(): void {
if (hasValue(this.sub)) {
this.sub.unsubscribe();
}
}
}

View File

@@ -0,0 +1,8 @@
<a *ngIf="isVisible | async" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="changeQueryParams" queryParamsHandling="merge">
<span class="filter-value px-1">{{filterValue.value}}</span>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{filterValue.count}}</span>
</span>
</a>

View File

@@ -0,0 +1,125 @@
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model';
import { FilterType } from '../../../../search-service/filter-type.model';
import { FacetValue } from '../../../../search-service/facet-value.model';
import { FormsModule } from '@angular/forms';
import { of as observableOf } from 'rxjs';
import { SearchService } from '../../../../search-service/search.service';
import { SearchServiceStub } from '../../../../../shared/testing/search-service-stub';
import { Router } from '@angular/router';
import { RouterStub } from '../../../../../shared/testing/router-stub';
import { SearchConfigurationService } from '../../../../search-service/search-configuration.service';
import { SearchFilterService } from '../../search-filter.service';
import { By } from '@angular/platform-browser';
import { SearchFacetRangeOptionComponent } from './search-facet-range-option.component';
import {
RANGE_FILTER_MAX_SUFFIX,
RANGE_FILTER_MIN_SUFFIX
} from '../../search-range-filter/search-range-filter.component';
describe('SearchFacetRangeOptionComponent', () => {
let comp: SearchFacetRangeOptionComponent;
let fixture: ComponentFixture<SearchFacetRangeOptionComponent>;
const filterName1 = 'test name';
const value2 = '20 - 30';
const mockFilterConfig = Object.assign(new SearchFilterConfig(), {
name: filterName1,
type: FilterType.range,
hasFacets: false,
isOpenByDefault: false,
pageSize: 2,
minValue: 200,
maxValue: 3000,
});
const value: FacetValue = {
value: value2,
count: 20,
search: ''
};
const searchLink = '/search';
let filterService;
let searchService;
let router;
const page = observableOf(0);
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
declarations: [SearchFacetRangeOptionComponent],
providers: [
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
{ provide: Router, useValue: new RouterStub() },
{
provide: SearchConfigurationService, useValue: {
searchOptions: observableOf({})
}
},
{
provide: SearchFilterService, useValue: {
isFilterActiveWithValue: (paramName: string, filterValue: string) => observableOf(true),
getPage: (paramName: string) => page,
/* tslint:disable:no-empty */
incrementPage: (filterName: string) => {
},
resetPage: (filterName: string) => {
}
/* tslint:enable:no-empty */
}
}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFacetRangeOptionComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchFacetRangeOptionComponent);
comp = fixture.componentInstance; // SearchFacetRangeOptionComponent test instance
filterService = (comp as any).filterService;
searchService = (comp as any).searchService;
router = (comp as any).router;
comp.filterValue = value;
comp.filterConfig = mockFilterConfig;
fixture.detectChanges();
});
describe('when the updateChangeParams method is called wih a value', () => {
it('should update the changeQueryParams with the new parameter values', () => {
comp.changeQueryParams = {};
comp.filterValue = {
value: '50-60',
count: 20,
search: ''
};
(comp as any).updateChangeParams();
expect(comp.changeQueryParams).toEqual({
[mockFilterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: ['50'],
[mockFilterConfig.paramName + RANGE_FILTER_MAX_SUFFIX]: ['60'],
page: 1
});
});
});
describe('when isVisible emits true', () => {
it('the facet option should be visible', () => {
comp.isVisible = observableOf(true);
fixture.detectChanges();
const linkEl = fixture.debugElement.query(By.css('a'));
expect(linkEl).not.toBeNull();
});
});
describe('when isVisible emits false', () => {
it('the facet option should not be visible', () => {
comp.isVisible = observableOf(false);
fixture.detectChanges();
const linkEl = fixture.debugElement.query(By.css('a'));
expect(linkEl).toBeNull();
});
});
});

View File

@@ -0,0 +1,105 @@
import { Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { FacetValue } from '../../../../search-service/facet-value.model';
import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model';
import { SearchService } from '../../../../search-service/search.service';
import { SearchFilterService } from '../../search-filter.service';
import {
RANGE_FILTER_MAX_SUFFIX,
RANGE_FILTER_MIN_SUFFIX
} from '../../search-range-filter/search-range-filter.component';
import { SearchConfigurationService } from '../../../../search-service/search-configuration.service';
import { hasValue } from '../../../../../shared/empty.util';
const rangeDelimiter = '-';
@Component({
selector: 'ds-search-facet-range-option',
templateUrl: './search-facet-range-option.component.html',
})
/**
* Represents a single option in a range filter facet
*/
export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy {
/**
* A single value for this component
*/
@Input() filterValue: FacetValue;
/**
* The filter configuration for this facet option
*/
@Input() filterConfig: SearchFilterConfig;
/**
* Emits true when this option should be visible and false when it should be invisible
*/
isVisible: Observable<boolean>;
/**
* UI parameters when this filter is changed
*/
changeQueryParams;
/**
* Subscription to unsubscribe from on destroy
*/
sub: Subscription;
constructor(protected searchService: SearchService,
protected filterService: SearchFilterService,
protected searchConfigService: SearchConfigurationService,
protected router: Router
) {
}
/**
* Initializes all observable instance variables and starts listening to them
*/
ngOnInit(): void {
this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked));
this.sub = this.searchConfigService.searchOptions.subscribe(() => {
this.updateChangeParams()
});
}
/**
* Checks if a value for this filter is currently active
*/
private isChecked(): Observable<boolean> {
return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, this.filterValue.value);
}
/**
* @returns {string} The base path to the search page
*/
getSearchLink() {
return this.searchService.getSearchLink();
}
/**
* Calculates the parameters that should change if a given values for this range filter would be changed
*/
private updateChangeParams(): void {
const parts = this.filterValue.value.split(rangeDelimiter);
const min = parts.length > 1 ? parts[0].trim() : this.filterValue.value;
const max = parts.length > 1 ? parts[1].trim() : this.filterValue.value;
this.changeQueryParams = {
[this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: [min],
[this.filterConfig.paramName + RANGE_FILTER_MAX_SUFFIX]: [max],
page: 1
};
}
/**
* Make sure the subscription is unsubscribed from when this component is destroyed
*/
ngOnDestroy(): void {
if (hasValue(this.sub)) {
this.sub.unsubscribe();
}
}
}

View File

@@ -0,0 +1,6 @@
<a class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="removeQueryParams" queryParamsHandling="merge">
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
<span class="filter-value pl-1">{{selectedValue}}</span>
</a>

View File

@@ -0,0 +1,95 @@
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model';
import { FilterType } from '../../../../search-service/filter-type.model';
import { FormsModule } from '@angular/forms';
import { of as observableOf } from 'rxjs';
import { SearchService } from '../../../../search-service/search.service';
import { SearchServiceStub } from '../../../../../shared/testing/search-service-stub';
import { Router } from '@angular/router';
import { RouterStub } from '../../../../../shared/testing/router-stub';
import { SearchConfigurationService } from '../../../../search-service/search-configuration.service';
import { SearchFilterService } from '../../search-filter.service';
import { SearchFacetSelectedOptionComponent } from './search-facet-selected-option.component';
describe('SearchFacetSelectedOptionComponent', () => {
let comp: SearchFacetSelectedOptionComponent;
let fixture: ComponentFixture<SearchFacetSelectedOptionComponent>;
const filterName1 = 'test name';
const value1 = 'testvalue1';
const value2 = 'test2';
const mockFilterConfig = Object.assign(new SearchFilterConfig(), {
name: filterName1,
type: FilterType.range,
hasFacets: false,
isOpenByDefault: false,
pageSize: 2,
minValue: 200,
maxValue: 3000,
});
const searchLink = '/search';
const selectedValues = [value1, value2];
const selectedValues$ = observableOf(selectedValues);
let filterService;
let searchService;
let router;
const page = observableOf(0);
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
declarations: [SearchFacetSelectedOptionComponent],
providers: [
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
{ provide: Router, useValue: new RouterStub() },
{
provide: SearchConfigurationService, useValue: {
searchOptions: observableOf({})
}
},
{
provide: SearchFilterService, useValue: {
getSelectedValuesForFilter: () => selectedValues,
isFilterActiveWithValue: (paramName: string, filterValue: string) => observableOf(true),
getPage: (paramName: string) => page,
/* tslint:disable:no-empty */
incrementPage: (filterName: string) => {
},
resetPage: (filterName: string) => {
}
/* tslint:enable:no-empty */
}
}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFacetSelectedOptionComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchFacetSelectedOptionComponent);
comp = fixture.componentInstance; // SearchFacetSelectedOptionComponent test instance
filterService = (comp as any).filterService;
searchService = (comp as any).searchService;
router = (comp as any).router;
comp.selectedValue = value2;
comp.selectedValues$ = selectedValues$;
comp.filterConfig = mockFilterConfig;
fixture.detectChanges();
});
describe('when the updateRemoveParams method is called wih a value', () => {
it('should update the removeQueryParams with the new parameter values', () => {
comp.removeQueryParams = {};
(comp as any).updateRemoveParams(selectedValues);
expect(comp.removeQueryParams).toEqual({
[mockFilterConfig.paramName]: [value1],
page: 1
});
});
});
});

View File

@@ -0,0 +1,87 @@
import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model';
import { SearchService } from '../../../../search-service/search.service';
import { SearchFilterService } from '../../search-filter.service';
import { hasValue } from '../../../../../shared/empty.util';
import { SearchConfigurationService } from '../../../../search-service/search-configuration.service';
@Component({
selector: 'ds-search-facet-selected-option',
templateUrl: './search-facet-selected-option.component.html',
})
/**
* Represents a single selected option in a filter facet
*/
export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy {
/**
* The value for this component
*/
@Input() selectedValue: string;
/**
* The filter configuration for this facet option
*/
@Input() filterConfig: SearchFilterConfig;
/**
* Emits the active values for this filter
*/
@Input() selectedValues$: Observable<string[]>;
/**
* UI parameters when this filter is removed
*/
removeQueryParams;
/**
* Subscription to unsubscribe from on destroy
*/
sub: Subscription;
constructor(protected searchService: SearchService,
protected filterService: SearchFilterService,
protected searchConfigService: SearchConfigurationService,
protected router: Router
) {
}
/**
* Initializes all observable instance variables and starts listening to them
*/
ngOnInit(): void {
this.sub = observableCombineLatest(this.selectedValues$, this.searchConfigService.searchOptions)
.subscribe(([selectedValues, searchOptions]) => {
this.updateRemoveParams(selectedValues)
});
}
/**
* @returns {string} The base path to the search page
*/
getSearchLink() {
return this.searchService.getSearchLink();
}
/**
* Calculates the parameters that should change if a given value for this filter would be removed from the active filters
* @param {string[]} selectedValues The values that are currently selected for this filter
*/
private updateRemoveParams(selectedValues: string[]): void {
this.removeQueryParams = {
[this.filterConfig.paramName]: selectedValues.filter((v) => v !== this.selectedValue),
page: 1
};
}
/**
* Make sure the subscription is unsubscribed from when this component is destroyed
*/
ngOnDestroy(): void {
if (hasValue(this.sub)) {
this.sub.unsubscribe();
}
}
}

View File

@@ -1 +1 @@
<ng-container *ngComponentOutlet="getSearchFilter(); injector: objectInjector;"></ng-container>
<ng-container *ngComponentOutlet="searchFilter injector: objectInjector;"></ng-container>

View File

@@ -3,6 +3,8 @@ import { renderFilterType } from '../search-filter-type-decorator';
import { FilterType } from '../../../search-service/filter-type.model';
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
import { FILTER_CONFIG } from '../search-filter.service';
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
import { SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component';
@Component({
selector: 'ds-search-facet-filter-wrapper',
@@ -18,6 +20,10 @@ export class SearchFacetFilterWrapperComponent implements OnInit {
*/
@Input() filterConfig: SearchFilterConfig;
/**
* The constructor of the search facet filter that should be rendered, based on the filter config's type
*/
searchFilter: GenericConstructor<SearchFacetFilterComponent>;
/**
* Injector to inject a child component with the @Input parameters
*/
@@ -30,6 +36,7 @@ export class SearchFacetFilterWrapperComponent implements OnInit {
* Initialize and add the filter config to the injector
*/
ngOnInit(): void {
this.searchFilter = this.getSearchFilter();
this.objectInjector = Injector.create({
providers: [
{ provide: FILTER_CONFIG, useFactory: () => (this.filterConfig), deps: [] }
@@ -41,7 +48,7 @@ export class SearchFacetFilterWrapperComponent implements OnInit {
/**
* Find the correct component based on the filter config's type
*/
getSearchFilter() {
private getSearchFilter() {
const type: FilterType = this.filterConfig.type;
return renderFilterType(type);
}

View File

@@ -120,20 +120,6 @@ describe('SearchFacetFilterComponent', () => {
});
});
describe('when the getAddParams method is called wih a value', () => {
it('should return the selectedValue list with the new parameter value', () => {
const result = comp.getAddParams(value3);
result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value1, value2, value3]));
});
});
describe('when the getRemoveParams method is called wih a value', () => {
it('should return the selectedValue list with the parameter value left out', () => {
const result = comp.getRemoveParams(value1);
result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value2]));
});
});
describe('when the showMore method is called', () => {
beforeEach(() => {
spyOn(filterService, 'incrementPage');

View File

@@ -22,6 +22,7 @@ import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
import { getSucceededRemoteData } from '../../../../core/shared/operators';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { SearchOptions } from '../../../search-options.model';
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
@Component({
@@ -66,7 +67,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
/**
* Emits the active values for this filter
*/
selectedValues: Observable<string[]>;
selectedValues$: Observable<string[]>;
private collapseNextUpdate = true;
/**
@@ -74,6 +75,11 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
*/
animationState = 'loading';
/**
* Emits all current search options available in the search URL
*/
searchOptions$: Observable<SearchOptions>;
constructor(protected searchService: SearchService,
protected filterService: SearchFilterService,
protected rdbs: RemoteDataBuildService,
@@ -88,10 +94,11 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined));
this.currentPage = this.getCurrentPage().pipe(distinctUntilChanged());
this.selectedValues = this.filterService.getSelectedValuesForFilter(this.filterConfig);
const searchOptions = this.searchConfigService.searchOptions;
this.subs.push(this.searchConfigService.searchOptions.subscribe(() => this.updateFilterValueList()));
const facetValues = observableCombineLatest(searchOptions, this.currentPage).pipe(
this.selectedValues$ = this.filterService.getSelectedValuesForFilter(this.filterConfig);
this.searchOptions$ = this.searchConfigService.searchOptions;
this.subs.push(this.searchOptions$.subscribe(() => this.updateFilterValueList()));
const facetValues = observableCombineLatest(this.searchOptions$, this.currentPage).pipe(
map(([options, page]) => {
return { options, page }
}),
@@ -191,7 +198,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
* @param data The string from the input field
*/
onSubmit(data: any) {
this.selectedValues.pipe(take(1)).subscribe((selectedValues) => {
this.selectedValues$.pipe(take(1)).subscribe((selectedValues) => {
if (isNotEmpty(data)) {
this.router.navigate([this.getSearchLink()], {
queryParams:
@@ -205,6 +212,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
)
}
/**
* On click, set the input's value to the clicked data
* @param data The value of the option that was clicked
*/
onClick(data: any) {
this.filter = data;
}
@@ -216,34 +227,6 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
return hasValue(o);
}
/**
* Calculates the parameters that should change if a given value for this filter would be removed from the active filters
* @param {string} value The value that is removed for this filter
* @returns {Observable<any>} The changed filter parameters
*/
getRemoveParams(value: string): Observable<any> {
return this.selectedValues.pipe(map((selectedValues) => {
return {
[this.filterConfig.paramName]: selectedValues.filter((v) => v !== value),
page: 1
};
}));
}
/**
* Calculates the parameters that should change if a given value for this filter would be added to the active filters
* @param {string} value The value that is added for this filter
* @returns {Observable<any>} The changed filter parameters
*/
getAddParams(value: string): Observable<any> {
return this.selectedValues.pipe(map((selectedValues) => {
return {
[this.filterConfig.paramName]: [...selectedValues, value],
page: 1
};
}));
}
/**
* Unsubscribe from all subscriptions
*/
@@ -260,7 +243,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
*/
findSuggestions(data): void {
if (isNotEmpty(data)) {
this.searchConfigService.searchOptions.pipe(take(1)).subscribe(
this.searchOptions$.pipe(take(1)).subscribe(
(options) => {
this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase())
.pipe(
@@ -291,6 +274,13 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
getDisplayValue(facet: FacetValue, query: string): string {
return new EmphasizePipe().transform(facet.value, query) + ' (' + facet.count + ')';
}
/**
* Prevent unnecessary rerendering
*/
trackUpdate(index, value: FacetValue) {
return value ? value.search : undefined;
}
}
export const facetLoad = trigger('facetLoad', [

View File

@@ -1,6 +1,7 @@
import { Action } from '@ngrx/store';
import { type } from '../../../shared/ngrx/type';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
/**
* For each action type in an action group, make a simple
@@ -12,9 +13,8 @@ import { type } from '../../../shared/ngrx/type';
*/
export const SearchFilterActionTypes = {
COLLAPSE: type('dspace/search-filter/COLLAPSE'),
INITIAL_COLLAPSE: type('dspace/search-filter/INITIAL_COLLAPSE'),
INITIALIZE: type('dspace/search-filter/INITIALIZE'),
EXPAND: type('dspace/search-filter/EXPAND'),
INITIAL_EXPAND: type('dspace/search-filter/INITIAL_EXPAND'),
TOGGLE: type('dspace/search-filter/TOGGLE'),
DECREMENT_PAGE: type('dspace/search-filter/DECREMENT_PAGE'),
INCREMENT_PAGE: type('dspace/search-filter/INCREMENT_PAGE'),
@@ -64,17 +64,15 @@ export class SearchFilterToggleAction extends SearchFilterAction {
}
/**
* Used to set the initial state of a filter to collapsed
* Used to set the initial state of a filter
*/
export class SearchFilterInitialCollapseAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIAL_COLLAPSE;
export class SearchFilterInitializeAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIALIZE;
initiallyExpanded;
constructor(filter: SearchFilterConfig) {
super(filter.name);
this.initiallyExpanded = filter.isOpenByDefault;
}
/**
* Used to set the initial state of a filter to expanded
*/
export class SearchFilterInitialExpandAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIAL_EXPAND;
}
/**

View File

@@ -1,7 +1,7 @@
<div>
<div *ngIf="active$ | async">
<div (click)="toggle()" class="filter-name"><h5 class="d-inline-block mb-0">{{'search.filters.filter.' + filter.name + '.head'| translate}}</h5> <span class="filter-toggle fas float-right"
[ngClass]="(isCollapsed() | async) ? 'fa-plus' : 'fa-minus'"></span></div>
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{'closed' : collapsed}">
[ngClass]="(collapsed$ | async) ? 'fa-plus' : 'fa-minus'"></span></div>
<div [@slide]="(collapsed$ | async) ? 'collapsed' : 'expanded'" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{'closed' : closed}">
<ds-search-facet-filter-wrapper [filterConfig]="filter"></ds-search-facet-filter-wrapper>
</div>
</div>

View File

@@ -10,6 +10,7 @@ import { SearchService } from '../../search-service/search.service';
import { SearchFilterComponent } from './search-filter.component';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { FilterType } from '../../search-service/filter-type.model';
import { SearchConfigurationService } from '../../search-service/search-configuration.service';
describe('SearchFilterComponent', () => {
let comp: SearchFilterComponent;
@@ -33,9 +34,7 @@ describe('SearchFilterComponent', () => {
},
expand: (filter) => {
},
initialCollapse: (filter) => {
},
initialExpand: (filter) => {
initializeFilter: (filter) => {
},
getSelectedValuesForFilter: (filter) => {
return observableOf([filterName1, filterName2, filterName3])
@@ -55,6 +54,8 @@ describe('SearchFilterComponent', () => {
getFacetValuesFor: (filter) => mockResults
};
const searchConfigServiceStub = {};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule],
@@ -65,6 +66,7 @@ describe('SearchFilterComponent', () => {
provide: SearchFilterService,
useValue: mockFilterService
},
{ provide: SearchConfigurationService, useValue: searchConfigServiceStub },
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFilterComponent, {
@@ -91,32 +93,21 @@ describe('SearchFilterComponent', () => {
});
});
describe('when the initialCollapse method is triggered', () => {
describe('when the initializeFilter method is triggered', () => {
beforeEach(() => {
spyOn(filterService, 'initialCollapse');
comp.initialCollapse();
spyOn(filterService, 'initializeFilter');
comp.initializeFilter();
});
it('should call initialCollapse with the correct filter configuration name', () => {
expect(filterService.initialCollapse).toHaveBeenCalledWith(mockFilterConfig.name)
});
});
describe('when the initialExpand method is triggered', () => {
beforeEach(() => {
spyOn(filterService, 'initialExpand');
comp.initialExpand();
});
it('should call initialCollapse with the correct filter configuration name', () => {
expect(filterService.initialExpand).toHaveBeenCalledWith(mockFilterConfig.name)
expect(filterService.initializeFilter).toHaveBeenCalledWith(mockFilterConfig)
});
});
describe('when getSelectedValues is called', () => {
let valuesObservable: Observable<string[]>;
beforeEach(() => {
valuesObservable = comp.getSelectedValues();
valuesObservable = (comp as any).getSelectedValues();
});
it('should return an observable containing the existing filters', () => {
@@ -141,7 +132,7 @@ describe('SearchFilterComponent', () => {
let isActive: Observable<boolean>;
beforeEach(() => {
filterService.isCollapsed = () => observableOf(true);
isActive = comp.isCollapsed();
isActive = (comp as any).isCollapsed();
});
it('should return an observable containing true', () => {
@@ -156,7 +147,7 @@ describe('SearchFilterComponent', () => {
let isActive: Observable<boolean>;
beforeEach(() => {
filterService.isCollapsed = () => observableOf(false);
isActive = comp.isCollapsed();
isActive = (comp as any).isCollapsed();
});
it('should return an observable containing false', () => {

View File

@@ -1,12 +1,14 @@
import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import { Observable, of as observableOf } from 'rxjs';
import { filter, first, map, startWith, switchMap, take } from 'rxjs/operators';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { SearchFilterService } from './search-filter.service';
import { slide } from '../../../shared/animations/slide';
import { isNotEmpty } from '../../../shared/empty.util';
import { SearchService } from '../../search-service/search.service';
import { SearchConfigurationService } from '../../search-service/search-configuration.service';
@Component({
selector: 'ds-search-filter',
@@ -27,9 +29,24 @@ export class SearchFilterComponent implements OnInit {
/**
* True when the filter is 100% collapsed in the UI
*/
collapsed;
closed = true;
constructor(private filterService: SearchFilterService) {
/**
* Emits true when the filter is currently collapsed in the store
*/
collapsed$: Observable<boolean>;
/**
* Emits all currently selected values for this filter
*/
selectedValues$: Observable<string[]>;
/**
* Emits true when the current filter is supposed to be shown
*/
active$: Observable<boolean>;
constructor(private filterService: SearchFilterService, private searchService: SearchService, private searchConfigService: SearchConfigurationService) {
}
/**
@@ -38,11 +55,13 @@ export class SearchFilterComponent implements OnInit {
* Else, the filter should initially be collapsed
*/
ngOnInit() {
this.getSelectedValues().pipe(take(1)).subscribe((isActive) => {
if (this.filter.isOpenByDefault || isNotEmpty(isActive)) {
this.initialExpand();
} else {
this.initialCollapse();
this.selectedValues$ = this.getSelectedValues();
this.active$ = this.isActive();
this.collapsed$ = this.isCollapsed();
this.initializeFilter();
this.selectedValues$.pipe(take(1)).subscribe((selectedValues) => {
if (isNotEmpty(selectedValues)) {
this.filterService.expand(this.filter.name);
}
});
}
@@ -58,30 +77,21 @@ export class SearchFilterComponent implements OnInit {
* Checks if the filter is currently collapsed
* @returns {Observable<boolean>} Emits true when the current state of the filter is collapsed, false when it's expanded
*/
isCollapsed(): Observable<boolean> {
private isCollapsed(): Observable<boolean> {
return this.filterService.isCollapsed(this.filter.name);
}
/**
* Changes the initial state to collapsed
* Sets the initial state of the filter
*/
initialCollapse() {
this.filterService.initialCollapse(this.filter.name);
this.collapsed = true;
}
/**
* Changes the initial state to expanded
*/
initialExpand() {
this.filterService.initialExpand(this.filter.name);
this.collapsed = false;
initializeFilter() {
this.filterService.initializeFilter(this.filter);
}
/**
* @returns {Observable<string[]>} Emits a list of all values that are currently active for this filter
*/
getSelectedValues(): Observable<string[]> {
private getSelectedValues(): Observable<string[]> {
return this.filterService.getSelectedValuesForFilter(this.filter);
}
@@ -91,7 +101,7 @@ export class SearchFilterComponent implements OnInit {
*/
finishSlide(event: any): void {
if (event.fromState === 'collapsed') {
this.collapsed = false;
this.closed = false;
}
}
@@ -101,7 +111,32 @@ export class SearchFilterComponent implements OnInit {
*/
startSlide(event: any): void {
if (event.toState === 'collapsed') {
this.collapsed = true;
this.closed = true;
}
}
/**
* Check if a given filter is supposed to be shown or not
* @returns {Observable<boolean>} Emits true whenever a given filter config should be shown
*/
private isActive(): Observable<boolean> {
return this.selectedValues$.pipe(
switchMap((isActive) => {
if (isNotEmpty(isActive)) {
return observableOf(true);
} else {
return this.searchConfigService.searchOptions.pipe(
first(),
switchMap((options) => {
return this.searchService.getFacetValuesFor(this.filter, 1, options).pipe(
filter((RD) => !RD.isLoading),
map((valuesRD) => {
return valuesRD.payload.totalElements > 0
}),)
}
))
}
}),
startWith(true));
}
}

View File

@@ -1,10 +1,8 @@
import * as deepFreeze from 'deep-freeze';
import {
SearchFilterCollapseAction, SearchFilterExpandAction, SearchFilterIncrementPageAction,
SearchFilterInitialCollapseAction,
SearchFilterInitialExpandAction,
SearchFilterToggleAction,
SearchFilterDecrementPageAction, SearchFilterResetPageAction
SearchFilterDecrementPageAction, SearchFilterResetPageAction, SearchFilterInitializeAction
} from './search-filter.actions';
import { filterReducer } from './search-filter.reducer';
@@ -98,35 +96,39 @@ describe('filterReducer', () => {
filterReducer(state, action);
});
it('should set filterCollapsed to true in response to the INITIAL_COLLAPSE action when no state has been set for this filter', () => {
it('should set filterCollapsed to true in response to the INITIALIZE action with isOpenByDefault to false when no state has been set for this filter', () => {
const state = {};
state[filterName2] = { filterCollapsed: false, page: 1 };
const action = new SearchFilterInitialCollapseAction(filterName1);
const filterConfig = {isOpenByDefault: false, name: filterName1} as any;
const action = new SearchFilterInitializeAction(filterConfig);
const newState = filterReducer(state, action);
expect(newState[filterName1].filterCollapsed).toEqual(true);
});
it('should set filterCollapsed to true in response to the INITIAL_EXPAND action when no state has been set for this filter', () => {
it('should set filterCollapsed to false in response to the INITIALIZE action with isOpenByDefault to true when no state has been set for this filter', () => {
const state = {};
state[filterName2] = { filterCollapsed: true, page: 1 };
const action = new SearchFilterInitialExpandAction(filterName1);
const filterConfig = {isOpenByDefault: true, name: filterName1} as any;
const action = new SearchFilterInitializeAction(filterConfig);
const newState = filterReducer(state, action);
expect(newState[filterName1].filterCollapsed).toEqual(false);
});
it('should not change the state in response to the INITIAL_COLLAPSE action when the state has already been set for this filter', () => {
it('should not change the state in response to the INITIALIZE action with isOpenByDefault to false when the state has already been set for this filter', () => {
const state = {};
state[filterName1] = { filterCollapsed: false, page: 1 };
const action = new SearchFilterInitialCollapseAction(filterName1);
const filterConfig = { isOpenByDefault: true, name: filterName1 } as any;
const action = new SearchFilterInitializeAction(filterConfig);
const newState = filterReducer(state, action);
expect(newState).toEqual(state);
});
it('should not change the state in response to the INITIAL_EXPAND action when the state has already been set for this filter', () => {
it('should not change the state in response to the INITIALIZE action with isOpenByDefault to true when the state has already been set for this filter', () => {
const state = {};
state[filterName1] = { filterCollapsed: true, page: 1 };
const action = new SearchFilterInitialExpandAction(filterName1);
const filterConfig = { isOpenByDefault: false, name: filterName1 } as any;
const action = new SearchFilterInitializeAction(filterConfig);
const newState = filterReducer(state, action);
expect(newState).toEqual(state);
});

View File

@@ -1,5 +1,9 @@
import { SearchFilterAction, SearchFilterActionTypes } from './search-filter.actions';
import { isEmpty } from '../../../shared/empty.util';
import {
SearchFilterAction,
SearchFilterActionTypes,
SearchFilterInitializeAction
} from './search-filter.actions';
import { isEmpty, isNotUndefined } from '../../../shared/empty.util';
/**
* Interface that represents the state for a single filters
@@ -28,27 +32,14 @@ export function filterReducer(state = initialState, action: SearchFilterAction):
switch (action.type) {
case SearchFilterActionTypes.INITIAL_COLLAPSE: {
if (isEmpty(state) || isEmpty(state[action.filterName])) {
case SearchFilterActionTypes.INITIALIZE: {
const initAction = (action as SearchFilterInitializeAction);
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: true,
filterCollapsed: !initAction.initiallyExpanded,
page: 1
}
});
}
return state;
}
case SearchFilterActionTypes.INITIAL_EXPAND: {
if (isEmpty(state) || isEmpty(state[action.filterName])) {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: false,
page: 1
}
});
}
return state;
}

View File

@@ -5,8 +5,7 @@ import {
SearchFilterDecrementPageAction,
SearchFilterExpandAction,
SearchFilterIncrementPageAction,
SearchFilterInitialCollapseAction,
SearchFilterInitialExpandAction,
SearchFilterInitializeAction,
SearchFilterResetPageAction,
SearchFilterToggleAction
} from './search-filter.actions';
@@ -62,23 +61,13 @@ describe('SearchFilterService', () => {
service = new SearchFilterService(store, routeServiceStub);
});
describe('when the initialCollapse method is triggered', () => {
describe('when the initializeFilter method is triggered', () => {
beforeEach(() => {
service.initialCollapse(mockFilterConfig.name);
service.initializeFilter(mockFilterConfig);
});
it('SearchFilterInitialCollapseAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitialCollapseAction(mockFilterConfig.name));
});
});
describe('when the initialExpand method is triggered', () => {
beforeEach(() => {
service.initialExpand(mockFilterConfig.name);
});
it('SearchFilterInitialExpandAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitialExpandAction(mockFilterConfig.name));
it('SearchFilterInitializeAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitializeAction(mockFilterConfig));
});
});

View File

@@ -1,6 +1,6 @@
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { Injectable, InjectionToken } from '@angular/core';
import { map } from 'rxjs/operators';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import {
@@ -8,8 +8,7 @@ import {
SearchFilterDecrementPageAction,
SearchFilterExpandAction,
SearchFilterIncrementPageAction,
SearchFilterInitialCollapseAction,
SearchFilterInitialExpandAction,
SearchFilterInitializeAction,
SearchFilterResetPageAction,
SearchFilterToggleAction
} from './search-filter.actions';
@@ -17,7 +16,8 @@ import { hasValue, isNotEmpty, } from '../../../shared/empty.util';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { RouteService } from '../../../shared/services/route.service';
import { Params } from '@angular/router';
import { SearchOptions } from '../../search-options.model';
// const spy = create();
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
export const FILTER_CONFIG: InjectionToken<SearchFilterConfig> = new InjectionToken<SearchFilterConfig>('filterConfig');
@@ -60,7 +60,7 @@ export class SearchFilterService {
getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable<string[]> {
const values$ = this.routeService.getQueryParameterValues(filterConfig.paramName);
const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').pipe(
map((params: Params) => [].concat(...Object.values(params)))
map((params: Params) => [].concat(...Object.values(params))),
);
return observableCombineLatest(values$, prefixValues$).pipe(
@@ -88,7 +88,8 @@ export class SearchFilterService {
} else {
return false;
}
})
}),
distinctUntilChanged()
);
}
@@ -106,7 +107,8 @@ export class SearchFilterService {
} else {
return 1;
}
}));
}),
distinctUntilChanged());
}
/**
@@ -134,19 +136,11 @@ export class SearchFilterService {
}
/**
* Dispatches an initial collapse action to the store for a given filter
* @param {string} filterName The filter for which the action is dispatched
* Dispatches an initialize action to the store for a given filter
* @param {SearchFilterConfig} filter The filter for which the action is dispatched
*/
public initialCollapse(filterName: string): void {
this.store.dispatch(new SearchFilterInitialCollapseAction(filterName));
}
/**
* Dispatches an initial expand action to the store for a given filter
* @param {string} filterName The filter for which the action is dispatched
*/
public initialExpand(filterName: string): void {
this.store.dispatch(new SearchFilterInitialExpandAction(filterName));
public initializeFilter(filter: SearchFilterConfig): void {
this.store.dispatch(new SearchFilterInitializeAction(filter));
}
/**

View File

@@ -1,24 +1,9 @@
<div>
<div class="filters py-2">
<a *ngFor="let value of (selectedValues | async)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getRemoveParams(value) | async" queryParamsHandling="merge">
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
<span class="filter-value pl-1">{{value}}</span>
</a>
<ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$"></ds-search-facet-selected-option>
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
<div [@facetLoad]="animationState">
<ng-container *ngFor="let value of page.page; let i=index">
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getAddParams(value.value) | async" queryParamsHandling="merge" >
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
<span class="filter-value px-1">{{value.value}}</span>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
</span>
</a>
</ng-container>
<ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$"></ds-search-facet-option>
</div>
</ng-container>
<div class="clearfix toggle-more-filters">

View File

@@ -24,16 +24,7 @@
</ng-container>
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
<div [@facetLoad]="animationState">
<ng-container *ngFor="let value of page.page; let i=index">
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getChangeParams(value.value) | async" queryParamsHandling="merge">
<span class="filter-value px-1">{{value.value}}</span>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
</span>
</a>
</ng-container>
<ds-search-facet-range-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value"></ds-search-facet-range-option>
</div>
</ng-container>
</div>

View File

@@ -106,16 +106,6 @@ describe('SearchRangeFilterComponent', () => {
fixture.detectChanges();
});
describe('when the getChangeParams method is called wih a value', () => {
it('should return the selectedValue list with the new parameter value', () => {
const result$ = comp.getChangeParams(value3);
result$.subscribe((result) => {
expect(result[mockFilterConfig.paramName + minSuffix]).toEqual(['1990']);
expect(result[mockFilterConfig.paramName + maxSuffix]).toEqual(['1992']);
});
});
});
describe('when the onSubmit method is called with data', () => {
const searchUrl = '/search/path';
// const data = { [mockFilterConfig.paramName + minSuffix]: '1900', [mockFilterConfig.paramName + maxSuffix]: '1950' };

View File

@@ -1,9 +1,4 @@
import {
of as observableOf,
combineLatest as observableCombineLatest,
Observable,
Subscription
} from 'rxjs';
import { combineLatest as observableCombineLatest, Subscription } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
import { isPlatformBrowser } from '@angular/common';
import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
@@ -24,16 +19,26 @@ import { hasValue } from '../../../../shared/empty.util';
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
/**
* The suffix for a range filters' minimum in the frontend URL
*/
export const RANGE_FILTER_MIN_SUFFIX = '.min';
/**
* The suffix for a range filters' maximum in the frontend URL
*/
export const RANGE_FILTER_MAX_SUFFIX = '.max';
/**
* The date formats that are possible to appear in a date filter
*/
const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD'];
/**
* This component renders a simple item page.
* The route parameter 'id' is used to request the item it represents.
* All fields of the item that should be displayed, are defined in its template.
*/
const minSuffix = '.min';
const maxSuffix = '.max';
const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD'];
const rangeDelimiter = '-';
@Component({
selector: 'ds-search-range-filter',
styleUrls: ['./search-range-filter.component.scss'],
@@ -86,8 +91,8 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
super.ngOnInit();
this.min = moment(this.filterConfig.minValue, dateFormats).year() || this.min;
this.max = moment(this.filterConfig.maxValue, dateFormats).year() || this.max;
const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + minSuffix).pipe(startWith(undefined));
const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + maxSuffix).pipe(startWith(undefined));
const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX).pipe(startWith(undefined));
const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + RANGE_FILTER_MAX_SUFFIX).pipe(startWith(undefined));
this.sub = observableCombineLatest(iniMin, iniMax).pipe(
map(([min, max]) => {
const minimum = hasValue(min) ? min : this.min;
@@ -97,23 +102,6 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
).subscribe((minmax) => this.range = minmax);
}
/**
* Calculates the parameters that should change if a given values for this range filter would be changed
* @param {string} value The values that are changed for this filter
* @returns {Observable<any>} The changed filter parameters
*/
getChangeParams(value: string) {
const parts = value.split(rangeDelimiter);
const min = parts.length > 1 ? parts[0].trim() : value;
const max = parts.length > 1 ? parts[1].trim() : value;
return observableOf(
{
[this.filterConfig.paramName + minSuffix]: [min],
[this.filterConfig.paramName + maxSuffix]: [max],
page: 1
});
}
/**
* Submits new custom range values to the range filter from the widget
*/
@@ -123,8 +111,8 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
this.router.navigate([this.getSearchLink()], {
queryParams:
{
[this.filterConfig.paramName + minSuffix]: newMin,
[this.filterConfig.paramName + maxSuffix]: newMax
[this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: newMin,
[this.filterConfig.paramName + RANGE_FILTER_MAX_SUFFIX]: newMax
},
queryParamsHandling: 'merge'
});
@@ -149,8 +137,4 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
this.sub.unsubscribe();
}
}
out(call) {
console.log(call);
}
}

View File

@@ -1,26 +1,9 @@
<div>
<div class="filters py-2">
<a *ngFor="let value of (selectedValues | async)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getRemoveParams(value) | async" queryParamsHandling="merge">
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
<span class="filter-value pl-1">{{value}}</span>
</a>
<ng-container *ngVar="(filterValues$ | async) as filterValuesRD">
<ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$"></ds-search-facet-selected-option>
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
<div [@facetLoad]="animationState">
<ng-container *ngFor="let page of filterValuesRD?.payload">
<ng-container *ngFor="let value of page.page; let i=index">
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getAddParams(value.value) | async" queryParamsHandling="merge" >
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
<span class="filter-value px-1">{{value.value}}</span>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
</span>
</a>
</ng-container>
</ng-container>
<ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$"></ds-search-facet-option>
</div>
</ng-container>
<div class="clearfix toggle-more-filters">
@@ -40,6 +23,5 @@
(submitSuggestion)="onSubmit($event)"
(clickSuggestion)="onClick($event)"
(findSuggestions)="findSuggestions($event)"
ngDefaultControl
></ds-input-suggestions>
ngDefaultControl></ds-input-suggestions>
</div>

View File

@@ -1,7 +1,7 @@
<h3>{{"search.filters.head" | translate}}</h3>
<div *ngIf="!isLoadingFilters$.value">
<div *ngFor="let filter of filters">
<div *ngIf="(filters | async)?.hasSucceeded">
<div *ngFor="let filter of (filters | async)?.payload; trackBy: trackUpdate">
<ds-search-filter class="d-block mb-3 p-3" [filter]="filter"></ds-search-filter>
</div>
</div>
<a class="btn btn-primary" [routerLink]="[getSearchLink()]" [queryParams]="clearParams | async" [queryParamsHandling]="merge" role="button">{{"search.filters.reset" | translate}}</a>
<a class="btn btn-primary" [routerLink]="[getSearchLink()]" [queryParams]="clearParams | async" queryParamsHandling="merge" role="button">{{"search.filters.reset" | translate}}</a>

View File

@@ -1,16 +1,14 @@
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { Component, Inject } from '@angular/core';
import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs';
import { filter, first, map, mergeMap, startWith, switchMap, tap } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SearchService } from '../search-service/search.service';
import { RemoteData } from '../../core/data/remote-data';
import { SearchFilterConfig } from '../search-service/search-filter-config.model';
import { SearchConfigurationService } from '../search-service/search-configuration.service';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { SearchFilterService } from './search-filter/search-filter.service';
import { getSucceededRemoteData } from '../../core/shared/operators';
import { PaginatedSearchOptions } from '../paginated-search-options.model';
import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component';
@Component({
@@ -22,12 +20,11 @@ import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.comp
/**
* This component represents the part of the search sidebar that contains filters.
*/
export class SearchFiltersComponent implements OnDestroy, OnInit {
export class SearchFiltersComponent {
/**
* An Array containing configuration about which filters are shown and how they are shown
* An observable containing configuration about which filters are shown and how they are shown
*/
filters: SearchFilterConfig[] = [];
filters: Observable<RemoteData<SearchFilterConfig[]>>;
/**
* List of all filters that are currently active with their value set to null.
@@ -35,44 +32,19 @@ export class SearchFiltersComponent implements OnDestroy, OnInit {
*/
clearParams;
/**
* A boolean representing load state of filters configuration
*/
isLoadingFilters$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
/**
* The current paginated search options
*/
searchOptions$: Observable<PaginatedSearchOptions>;
private sub: Subscription;
/**
* Initialize instance variables
* @param {ChangeDetectorRef} cdr
* @param {SearchService} searchService
* @param {SearchConfigurationService} searchConfigService
* @param {SearchFilterService} filterService
*/
constructor(
private cdr: ChangeDetectorRef,
private searchService: SearchService,
@Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService,
private filterService: SearchFilterService) {
}
ngOnInit(): void {
this.searchOptions$ = this.searchConfigService.searchOptions;
this.sub = this.searchOptions$.pipe(
tap(() => this.setLoading()),
switchMap((options) => this.searchService.getConfig(options.scope, options.configuration).pipe(getSucceededRemoteData())))
.subscribe((filtersRD: RemoteData<SearchFilterConfig[]>) => {
this.filters = filtersRD.payload;
this.isLoadingFilters$.next(false);
});
this.clearParams = this.searchConfigService.getCurrentFrontendFilters().pipe(map((filters) => {
this.filters = searchService.getConfig().pipe(getSucceededRemoteData());
this.clearParams = searchConfigService.getCurrentFrontendFilters().pipe(map((filters) => {
Object.keys(filters).forEach((f) => filters[f] = null);
return filters;
}));
@@ -86,41 +58,9 @@ export class SearchFiltersComponent implements OnDestroy, OnInit {
}
/**
* Check if a given filter is supposed to be shown or not
* @param {SearchFilterConfig} filter The filter to check for
* @returns {Observable<boolean>} Emits true whenever a given filter config should be shown
* Prevent unnecessary rerendering
*/
isActive(filterConfig: SearchFilterConfig): Observable<boolean> {
console.log('isActive', filterConfig);
return this.filterService.getSelectedValuesForFilter(filterConfig).pipe(
mergeMap((isActive) => {
if (isNotEmpty(isActive)) {
return observableOf(true);
} else {
return this.searchOptions$.pipe(
switchMap((options) => {
return this.searchService.getFacetValuesFor(filterConfig, 1, options).pipe(
filter((RD) => !RD.isLoading),
map((valuesRD) => {
return valuesRD.payload.totalElements > 0
}),)
}
))
}
}),
first(),
startWith(true),);
}
private setLoading() {
this.isLoadingFilters$.next(true);
this.cdr.detectChanges();
}
ngOnDestroy(): void {
if (hasValue(this.sub)) {
this.sub.unsubscribe();
trackUpdate(index, config: SearchFilterConfig) {
return config ? config.name : undefined;
}
}
}

View File

@@ -11,7 +11,6 @@ import { CommunitySearchResultListElementComponent } from '../shared/object-list
import { ItemSearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component';
import { CommunitySearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component'
import { CollectionSearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component';
import { SearchService } from './search-service/search.service';
import { SearchSidebarComponent } from './search-sidebar/search-sidebar.component';
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
import { SearchSidebarEffects } from './search-sidebar/search-sidebar.effects';
@@ -28,6 +27,9 @@ import { SearchFacetFilterWrapperComponent } from './search-filters/search-filte
import { SearchBooleanFilterComponent } from './search-filters/search-filter/search-boolean-filter/search-boolean-filter.component';
import { SearchHierarchyFilterComponent } from './search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component';
import { SearchConfigurationService } from './search-service/search-configuration.service';
import { SearchFacetOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component';
import { SearchFacetSelectedOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component';
import { SearchFacetRangeOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component';
import { SearchSwitchConfigurationComponent } from './search-switch-configuration/search-switch-configuration.component';
const effects = [
@@ -56,6 +58,9 @@ const components = [
SearchTextFilterComponent,
SearchHierarchyFilterComponent,
SearchBooleanFilterComponent,
SearchFacetOptionComponent,
SearchFacetSelectedOptionComponent,
SearchFacetRangeOptionComponent,
SearchSwitchConfigurationComponent
];
@@ -69,7 +74,6 @@ const components = [
],
declarations: components,
providers: [
SearchService,
SearchSidebarService,
SearchFilterService,
SearchConfigurationService
@@ -86,6 +90,9 @@ const components = [
SearchTextFilterComponent,
SearchHierarchyFilterComponent,
SearchBooleanFilterComponent,
SearchFacetOptionComponent,
SearchFacetSelectedOptionComponent,
SearchFacetRangeOptionComponent
],
exports: components
})

View File

@@ -117,7 +117,7 @@ describe('SearchConfigurationService', () => {
describe('when subscribeToSearchOptions is called', () => {
beforeEach(() => {
service.subscribeToSearchOptions(defaults)
(service as any).subscribeToSearchOptions(defaults)
});
it('should call all getters it needs, but not call any others', () => {
expect(service.getCurrentPagination).not.toHaveBeenCalled();
@@ -131,7 +131,7 @@ describe('SearchConfigurationService', () => {
describe('when subscribeToPaginatedSearchOptions is called', () => {
beforeEach(() => {
service.subscribeToPaginatedSearchOptions(defaults);
(service as any).subscribeToPaginatedSearchOptions(defaults);
});
it('should call all getters it needs', () => {
expect(service.getCurrentPagination).toHaveBeenCalled();

View File

@@ -203,7 +203,7 @@ export class SearchConfigurationService implements OnDestroy {
* @param {SearchOptions} defaults Default values for when no parameters are available
* @returns {Subscription} The subscription to unsubscribe from
*/
subscribeToSearchOptions(defaults: SearchOptions): Subscription {
private subscribeToSearchOptions(defaults: SearchOptions): Subscription {
return observableMerge(
this.getConfigurationPart(defaults.configuration),
this.getScopePart(defaults.scope),
@@ -222,7 +222,7 @@ export class SearchConfigurationService implements OnDestroy {
* @param {PaginatedSearchOptions} defaults Default values for when no parameters are available
* @returns {Subscription} The subscription to unsubscribe from
*/
subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription {
private subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription {
return observableMerge(
this.getPaginationPart(defaults.pagination),
this.getSortPart(defaults.sort),

View File

@@ -16,7 +16,11 @@ import { RequestService } from '../../core/data/request.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { configureRequest, getResponseFromEntry, getSucceededRemoteData } from '../../core/shared/operators';
import {
configureRequest, filterSuccessfulResponses,
getResponseFromEntry,
getSucceededRemoteData
} from '../../core/shared/operators';
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
import { NormalizedSearchResult } from '../normalized-search-result.model';
@@ -121,14 +125,13 @@ export class SearchService implements OnDestroy {
// get search results from response cache
const sqrObs: Observable<SearchQueryResponse> = requestEntryObs.pipe(
getResponseFromEntry(),
filterSuccessfulResponses(),
map((response: SearchSuccessResponse) => response.results)
);
// turn dspace href from search results to effective list of DSpaceObjects
// Turn list of observable remote data DSO's into observable remote data object with list of DSO
const dsoObs: Observable<RemoteData<DSpaceObject[]>> = sqrObs.pipe(
// filter((sqr: SearchQueryResponse) => isNotUndefined(sqr)),
map((sqr: SearchQueryResponse) => {
return sqr.objects
.filter((nsr: NormalizedSearchResult) => isNotUndefined(nsr.dspaceObject))
@@ -154,7 +157,6 @@ export class SearchService implements OnDestroy {
return undefined;
}
});
// .filter((object) => isNotUndefined(object));
})
);

View File

@@ -8,13 +8,21 @@ const ITEM_MODULE_PATH = 'items';
export function getItemModulePath() {
return `/${ITEM_MODULE_PATH}`;
}
const COLLECTION_MODULE_PATH = 'collections';
export function getCollectionModulePath() {
return `/${COLLECTION_MODULE_PATH}`;
}
const COMMUNITY_MODULE_PATH = 'communities';
export function getCommunityModulePath() {
return `/${COMMUNITY_MODULE_PATH}`;
}
@NgModule({
imports: [
RouterModule.forRoot([
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule' },
{ path: 'communities', loadChildren: './+community-page/community-page.module#CommunityPageModule' },
{ path: 'collections', loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
{ path: 'mydspace', loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', canActivate: [AuthenticatedGuard] },
{ path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' },

View File

@@ -46,13 +46,13 @@ export class RemoteDataBuildService {
const payload$ =
observableCombineLatest(
href$.pipe(
switchMap((href: string) => this.objectCache.getBySelfLink<T>(href)),
switchMap((href: string) => this.objectCache.getObjectBySelfLink<T>(href)),
startWith(undefined)),
requestEntry$.pipe(
getResourceLinksFromResponse(),
switchMap((resourceSelfLinks: string[]) => {
if (isNotEmpty(resourceSelfLinks)) {
return this.objectCache.getBySelfLink<T>(resourceSelfLinks[0]);
return this.objectCache.getObjectBySelfLink<T>(resourceSelfLinks[0]);
} else {
return observableOf(undefined);
}

View File

@@ -80,7 +80,7 @@ describe('ObjectCacheService', () => {
});
// due to the implementation of spyOn above, this subscribe will be synchronous
service.getBySelfLink(selfLink).pipe(first()).subscribe((o) => {
service.getObjectBySelfLink(selfLink).pipe(first()).subscribe((o) => {
expect(o.self).toBe(selfLink);
// this only works if testObj is an instance of TestClass
expect(o instanceof NormalizedItem).toBeTruthy();
@@ -96,7 +96,7 @@ describe('ObjectCacheService', () => {
});
let getObsHasFired = false;
const subscription = service.getBySelfLink(selfLink).subscribe((o) => getObsHasFired = true);
const subscription = service.getObjectBySelfLink(selfLink).subscribe((o) => getObsHasFired = true);
expect(getObsHasFired).toBe(false);
subscription.unsubscribe();
});
@@ -106,7 +106,7 @@ describe('ObjectCacheService', () => {
it('should return an observable of the array of cached objects with the specified self link and type', () => {
const item = new NormalizedItem();
item.self = selfLink;
spyOn(service, 'getBySelfLink').and.returnValue(observableOf(item));
spyOn(service, 'getObjectBySelfLink').and.returnValue(observableOf(item));
service.getList([selfLink, selfLink]).pipe(first()).subscribe((arr) => {
expect(arr[0].self).toBe(selfLink);

View File

@@ -1,34 +1,44 @@
import { Injectable } from '@angular/core';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { applyPatch, Operation } from 'fast-json-patch';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { MemoizedSelector, select, Store } from '@ngrx/store';
import { IndexName } from '../index/index.reducer';
import { CacheableObject, ObjectCacheEntry } from './object-cache.reducer';
import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
import { CoreState } from '../core.reducers';
import { coreSelector } from '../core.selectors';
import { RestRequestMethod } from '../data/rest-request-method';
import { selfLinkFromUuidSelector } from '../index/index.selectors';
import { GenericConstructor } from '../shared/generic-constructor';
import { NormalizedObjectFactory } from './models/normalized-object-factory';
import { NormalizedObject } from './models/normalized-object.model';
import {
AddPatchObjectCacheAction,
AddToObjectCacheAction,
ApplyPatchObjectCacheAction,
RemoveFromObjectCacheAction
} from './object-cache.actions';
import { hasNoValue, isNotEmpty } from '../../shared/empty.util';
import { GenericConstructor } from '../shared/generic-constructor';
import { coreSelector, CoreState } from '../core.reducers';
import { pathSelector } from '../shared/selectors';
import { NormalizedObjectFactory } from './models/normalized-object-factory';
import { NormalizedObject } from './models/normalized-object.model';
import { applyPatch, Operation } from 'fast-json-patch';
import { CacheableObject, ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer';
import { AddToSSBAction } from './server-sync-buffer.actions';
import { RestRequestMethod } from '../data/rest-request-method';
function selfLinkFromUuidSelector(uuid: string): MemoizedSelector<CoreState, string> {
return pathSelector<CoreState, string>(coreSelector, 'index', IndexName.OBJECT, uuid);
}
/**
* The base selector function to select the object cache in the store
*/
const objectCacheSelector = createSelector(
coreSelector,
(state: CoreState) => state['cache/object']
);
function entryFromSelfLinkSelector(selfLink: string): MemoizedSelector<CoreState, ObjectCacheEntry> {
return pathSelector<CoreState, ObjectCacheEntry>(coreSelector, 'cache/object', selfLink);
}
/**
* Selector function to select an object entry by self link from the cache
* @param selfLink The self link of the object
*/
const entryFromSelfLinkSelector =
(selfLink: string): MemoizedSelector<CoreState, ObjectCacheEntry> => createSelector(
objectCacheSelector,
(state: ObjectCacheState) => state[selfLink],
);
/**
* A service to interact with the object cache
@@ -65,29 +75,29 @@ export class ObjectCacheService {
/**
* Get an observable of the object with the specified UUID
*
* The type needs to be specified as well, in order to turn
* the cached plain javascript object in to an instance of
* a class.
*
* e.g. getByUUID('c96588c6-72d3-425d-9d47-fa896255a695', Item)
*
* @param uuid
* The UUID of the object to get
* @param type
* The type of the object to get
* @return Observable<T>
* An observable of the requested object
* @return Observable<NormalizedObject<T>>
* An observable of the requested object in normalized form
*/
getByUUID<T extends CacheableObject>(uuid: string): Observable<NormalizedObject<T>> {
getObjectByUUID<T extends CacheableObject>(uuid: string): Observable<NormalizedObject<T>> {
return this.store.pipe(
select(selfLinkFromUuidSelector(uuid)),
mergeMap((selfLink: string) => this.getBySelfLink(selfLink)
mergeMap((selfLink: string) => this.getObjectBySelfLink(selfLink)
)
)
}
getBySelfLink<T extends CacheableObject>(selfLink: string): Observable<NormalizedObject<T>> {
return this.getEntry(selfLink).pipe(
/**
* Get an observable of the object with the specified selfLink
*
* @param selfLink
* The selfLink of the object to get
* @return Observable<NormalizedObject<T>>
* An observable of the requested object in normalized form
*/
getObjectBySelfLink<T extends CacheableObject>(selfLink: string): Observable<NormalizedObject<T>> {
return this.getBySelfLink(selfLink).pipe(
map((entry: ObjectCacheEntry) => {
if (isNotEmpty(entry.patches)) {
const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations));
@@ -105,7 +115,15 @@ export class ObjectCacheService {
);
}
private getEntry(selfLink: string): Observable<ObjectCacheEntry> {
/**
* Get an observable of the object cache entry with the specified selfLink
*
* @param selfLink
* The selfLink of the object to get
* @return Observable<ObjectCacheEntry>
* An observable of the requested object cache entry
*/
getBySelfLink(selfLink: string): Observable<ObjectCacheEntry> {
return this.store.pipe(
select(entryFromSelfLinkSelector(selfLink)),
filter((entry) => this.isValid(entry)),
@@ -113,12 +131,28 @@ export class ObjectCacheService {
);
}
/**
* Get an observable of the request's uuid with the specified selfLink
*
* @param selfLink
* The selfLink of the object to get
* @return Observable<string>
* An observable of the request's uuid
*/
getRequestUUIDBySelfLink(selfLink: string): Observable<string> {
return this.getEntry(selfLink).pipe(
return this.getBySelfLink(selfLink).pipe(
map((entry: ObjectCacheEntry) => entry.requestUUID),
distinctUntilChanged());
}
/**
* Get an observable of the request's uuid with the specified uuid
*
* @param uuid
* The uuid of the object to get
* @return Observable<string>
* An observable of the request's uuid
*/
getRequestUUIDByObjectUUID(uuid: string): Observable<string> {
return this.store.pipe(
select(selfLinkFromUuidSelector(uuid)),
@@ -147,7 +181,7 @@ export class ObjectCacheService {
*/
getList<T extends CacheableObject>(selfLinks: string[]): Observable<Array<NormalizedObject<T>>> {
return observableCombineLatest(
selfLinks.map((selfLink: string) => this.getBySelfLink<T>(selfLink))
selfLinks.map((selfLink: string) => this.getObjectBySelfLink<T>(selfLink))
);
}

View File

@@ -46,7 +46,7 @@ describe('ServerSyncBufferEffects', () => {
{ provide: RequestService, useValue: getMockRequestService() },
{
provide: ObjectCacheService, useValue: {
getBySelfLink: (link) => {
getObjectBySelfLink: (link) => {
const object = new DSpaceObject();
object.self = link;
return observableOf(object);

View File

@@ -1,6 +1,7 @@
import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators';
import { Inject, Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { coreSelector } from '../core.selectors';
import {
AddToSSBAction,
CommitSSBAction,
@@ -9,7 +10,7 @@ import {
} from './server-sync-buffer.actions';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
import { coreSelector, CoreState } from '../core.reducers';
import { CoreState } from '../core.reducers';
import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
@@ -95,7 +96,7 @@ export class ServerSyncBufferEffects {
* @returns {Observable<Action>} ApplyPatchObjectCacheAction to be dispatched
*/
private applyPatch(href: string): Observable<Action> {
const patchObject = this.objectCache.getBySelfLink(href).pipe(take(1));
const patchObject = this.objectCache.getObjectBySelfLink(href).pipe(take(1));
return patchObject.pipe(
map((object) => {

View File

@@ -79,6 +79,7 @@ import { NormalizedObjectBuildService } from './cache/builders/normalized-object
import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service';
import { ObjectUpdatesService } from './data/object-updates/object-updates.service';
import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service';
import { SearchService } from '../+search-page/search-service/search.service';
import { RoleService } from './roles/role.service';
import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard';
import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service';
@@ -169,6 +170,7 @@ const PROVIDERS = [
CSSVariableService,
MenuService,
ObjectUpdatesService,
SearchService,
MyDSpaceGuard,
RoleService,
MessageResponseParsingService,

View File

@@ -4,7 +4,7 @@ import {
} from '@ngrx/store';
import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer';
import { indexReducer, IndexState } from './index/index.reducer';
import { indexReducer, MetaIndexState } from './index/index.reducer';
import { requestReducer, RequestState } from './data/request.reducer';
import { authReducer, AuthState } from './auth/auth.reducer';
import { jsonPatchOperationsReducer, JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer';
@@ -19,7 +19,7 @@ export interface CoreState {
'cache/syncbuffer': ServerSyncBufferState,
'cache/object-updates': ObjectUpdatesState
'data/request': RequestState,
'index': IndexState,
'index': MetaIndexState,
'auth': AuthState,
'json/patch': JsonPatchOperationsState
}
@@ -33,5 +33,3 @@ export const coreReducers: ActionReducerMap<CoreState> = {
'auth': authReducer,
'json/patch': jsonPatchOperationsReducer
};
export const coreSelector = createFeatureSelector<CoreState>('core');

View File

@@ -0,0 +1,7 @@
import { createFeatureSelector } from '@ngrx/store';
import { CoreState } from './core.reducers';
/**
* Base selector to select the core state from the store
*/
export const coreSelector = createFeatureSelector<CoreState>('core');

View File

@@ -95,7 +95,7 @@ describe('ComColDataService', () => {
function initMockObjectCacheService(): ObjectCacheService {
return jasmine.createSpyObj('objectCache', {
getByUUID: cold('d-', {
getObjectByUUID: cold('d-', {
d: {
_links: {
[LINK_NAME]: scopedEndpoint
@@ -160,7 +160,7 @@ describe('ComColDataService', () => {
it('should fetch the scope Community from the cache', () => {
scheduler.schedule(() => service.getBrowseEndpoint(options).subscribe());
scheduler.flush();
expect(objectCache.getByUUID).toHaveBeenCalledWith(scopeID);
expect(objectCache.getObjectByUUID).toHaveBeenCalledWith(scopeID);
});
it('should return the endpoint to fetch resources within the given scope', () => {

View File

@@ -49,7 +49,7 @@ export abstract class ComColDataService<T extends CacheableObject> extends DataS
);
const successResponses = responses.pipe(
filter((response) => response.isSuccessful),
mergeMap(() => this.objectCache.getByUUID(options.scopeID)),
mergeMap(() => this.objectCache.getObjectByUUID(options.scopeID)),
map((nc: NormalizedCommunity) => nc._links[linkPath]),
filter((href) => isNotEmpty(href))
);

View File

@@ -69,7 +69,7 @@ describe('DataService', () => {
addPatch: () => {
/* empty */
},
getBySelfLink: () => {
getObjectBySelfLink: () => {
/* empty */
}
} as any;
@@ -191,7 +191,7 @@ describe('DataService', () => {
dso2.metadata = [{ key: 'dc.title', value: name2 }];
spyOn(service, 'findById').and.returnValues(observableOf(dso));
spyOn(objectCache, 'getBySelfLink').and.returnValues(observableOf(dso));
spyOn(objectCache, 'getObjectBySelfLink').and.returnValues(observableOf(dso));
spyOn(objectCache, 'addPatch');
});

View File

@@ -175,7 +175,7 @@ export abstract class DataService<T extends CacheableObject> {
* @param {DSpaceObject} object The given object
*/
update(object: T): Observable<RemoteData<T>> {
const oldVersion$ = this.objectCache.getBySelfLink(object.self);
const oldVersion$ = this.objectCache.getObjectBySelfLink(object.self);
return oldVersion$.pipe(take(1), mergeMap((oldVersion: T) => {
const operations = this.comparator.diff(oldVersion, object);
if (isNotEmpty(operations)) {

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { coreSelector, CoreState } from '../../core.reducers';
import { CoreState } from '../../core.reducers';
import { coreSelector } from '../../core.selectors';
import {
FieldState,
FieldUpdates,

View File

@@ -17,6 +17,7 @@ import { BrowseItemsResponseParsingService } from './browse-items-response-parsi
import { RegistryMetadataschemasResponseParsingService } from './registry-metadataschemas-response-parsing.service';
import { MetadataschemaParsingService } from './metadataschema-parsing.service';
import { MetadatafieldParsingService } from './metadatafield-parsing.service';
import { URLCombiner } from '../url-combiner/url-combiner';
import { TaskResponseParsingService } from '../tasks/task-response-parsing.service';
import { MessageResponseParsingService } from '../message/message-response-parsing.service';
@@ -152,11 +153,11 @@ export class FindAllRequest extends GetRequest {
export class EndpointMapRequest extends GetRequest {
constructor(
public uuid: string,
public href: string,
public body?: any
uuid: string,
href: string,
body?: any
) {
super(uuid, href, body);
super(uuid, new URLCombiner(href, '?endpointMap').toString(), body);
}
getResponseParser(): GenericConstructor<ResponseParsingService> {

View File

@@ -1,7 +1,7 @@
import * as ngrx from '@ngrx/store';
import { ActionsSubject, Store } from '@ngrx/store';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { EMPTY, of as observableOf } from 'rxjs';
import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service';
import { defaultUUID, getMockUUIDService } from '../../shared/mocks/mock-uuid.service';
import { ObjectCacheService } from '../cache/object-cache.service';
@@ -21,8 +21,6 @@ import {
import { RequestService } from './request.service';
import { TestScheduler } from 'rxjs/testing';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { MockStore } from '../../shared/testing/mock-store';
import { IndexState } from '../index/index.reducer';
describe('RequestService', () => {
let scheduler: TestScheduler;
@@ -42,6 +40,7 @@ describe('RequestService', () => {
const testHeadRequest = new HeadRequest(testUUID, testHref);
const testPatchRequest = new PatchRequest(testUUID, testHref);
let selectSpy;
beforeEach(() => {
scheduler = getTestScheduler();
@@ -299,6 +298,7 @@ describe('RequestService', () => {
describe('in the ObjectCache', () => {
beforeEach(() => {
(objectCache.hasBySelfLink as any).and.returnValue(true);
spyOn(serviceAsAny, 'hasByHref').and.returnValue(false);
});
it('should return true', () => {
@@ -308,57 +308,11 @@ describe('RequestService', () => {
expect(result).toEqual(expected);
});
});
describe('in the responseCache', () => {
beforeEach(() => {
spyOn(serviceAsAny, 'isReusable').and.returnValue(observableOf(true));
spyOn(serviceAsAny, 'getByHref').and.returnValue(observableOf(undefined));
});
describe('and it\'s a DSOSuccessResponse', () => {
beforeEach(() => {
(serviceAsAny.getByHref as any).and.returnValue(observableOf({
response: {
isSuccessful: true,
resourceSelfLinks: [
'https://rest.api/endpoint/selfLink1',
'https://rest.api/endpoint/selfLink2'
]
}
}
));
});
it('should return true if all top level links in the response are cached in the object cache', () => {
(objectCache.hasBySelfLink as any).and.returnValues(false, true, true);
const result = serviceAsAny.isCachedOrPending(testGetRequest);
const expected = true;
expect(result).toEqual(expected);
});
it('should return false if not all top level links in the response are cached in the object cache', () => {
(objectCache.hasBySelfLink as any).and.returnValues(false, true, false);
spyOn(service, 'isPending').and.returnValue(false);
const result = serviceAsAny.isCachedOrPending(testGetRequest);
const expected = false;
expect(result).toEqual(expected);
});
});
describe('and it isn\'t a DSOSuccessResponse', () => {
describe('in the request cache', () => {
beforeEach(() => {
(objectCache.hasBySelfLink as any).and.returnValue(false);
(service as any).isReusable.and.returnValue(observableOf(true));
(serviceAsAny.getByHref as any).and.returnValue(observableOf({
response: {
isSuccessful: true
}
}
));
spyOn(serviceAsAny, 'hasByHref').and.returnValue(true);
});
it('should return true', () => {
const result = serviceAsAny.isCachedOrPending(testGetRequest);
const expected = true;
@@ -367,7 +321,6 @@ describe('RequestService', () => {
});
});
});
});
describe('when the request is pending', () => {
beforeEach(() => {
@@ -462,89 +415,77 @@ describe('RequestService', () => {
});
});
describe('isReusable', () => {
describe('when the given UUID is has no value', () => {
let reusable;
describe('isValid', () => {
describe('when the given entry has no value', () => {
let valid;
beforeEach(() => {
const uuid = undefined;
reusable = serviceAsAny.isReusable(uuid);
const entry = undefined;
valid = serviceAsAny.isValid(entry);
});
it('return an observable emitting false', () => {
reusable.subscribe((isReusable) => expect(isReusable).toBe(false));
expect(valid).toBe(false);
})
});
describe('when the given UUID has a value, but no cached entry is found', () => {
let reusable;
describe('when the given entry has a value, but the request is not completed', () => {
let valid;
const requestEntry = { completed: false };
beforeEach(() => {
spyOn(service, 'getByUUID').and.returnValue(observableOf(undefined));
const uuid = 'a45bb291-1adb-40d9-b2fc-7ad9080607be';
reusable = serviceAsAny.isReusable(uuid);
spyOn(service, 'getByUUID').and.returnValue(observableOf(requestEntry));
valid = serviceAsAny.isValid(requestEntry);
});
it('return an observable emitting false', () => {
reusable.subscribe((isReusable) => expect(isReusable).toBe(false));
expect(valid).toBe(false);
})
});
describe('when the given UUID has a value, a cached entry is found, but it has no response', () => {
let reusable;
describe('when the given entry has a value, but the response is not successful', () => {
let valid;
const requestEntry = { completed: true, response: { isSuccessful: false } };
beforeEach(() => {
spyOn(service, 'getByUUID').and.returnValue(observableOf({ response: undefined }));
const uuid = '53c9b814-ad8b-4567-9bc1-d9bb6cfba6c8';
reusable = serviceAsAny.isReusable(uuid);
spyOn(service, 'getByUUID').and.returnValue(observableOf(requestEntry));
valid = serviceAsAny.isValid(requestEntry);
});
it('return an observable emitting false', () => {
reusable.subscribe((isReusable) => expect(isReusable).toBe(false));
expect(valid).toBe(false);
})
});
describe('when the given UUID has a value, a cached entry is found, but its response was not successful', () => {
let reusable;
beforeEach(() => {
spyOn(service, 'getByUUID').and.returnValue(observableOf({ response: { isSuccessful: false } }));
const uuid = '694c9b32-7b2e-4788-835b-ef3fc2252e6c';
reusable = serviceAsAny.isReusable(uuid);
});
it('return an observable emitting false', () => {
reusable.subscribe((isReusable) => expect(isReusable).toBe(false));
})
});
describe('when the given UUID has a value, a cached entry is found, its response was successful, but the response is outdated', () => {
let reusable;
describe('when the given UUID has a value, its response was successful, but the response is outdated', () => {
let valid;
const now = 100000;
const timeAdded = 99899;
const msToLive = 100;
beforeEach(() => {
spyOn(Date.prototype, 'getTime').and.returnValue(now);
spyOn(service, 'getByUUID').and.returnValue(observableOf({
const requestEntry = {
completed: true,
response: {
isSuccessful: true,
timeAdded: timeAdded
},
request: {
responseMsToLive: msToLive
responseMsToLive: msToLive,
}
}));
const uuid = 'f9b85788-881c-4994-86b6-bae8dad024d2';
reusable = serviceAsAny.isReusable(uuid);
};
beforeEach(() => {
spyOn(Date.prototype, 'getTime').and.returnValue(now);
spyOn(service, 'getByUUID').and.returnValue(observableOf(requestEntry));
valid = serviceAsAny.isValid(requestEntry);
});
it('return an observable emitting false', () => {
reusable.subscribe((isReusable) => expect(isReusable).toBe(false));
expect(valid).toBe(false);
})
});
describe('when the given UUID has a value, a cached entry is found, its response was successful, and the response is not outdated', () => {
let reusable;
let valid;
const now = 100000;
const timeAdded = 99999;
const msToLive = 100;
beforeEach(() => {
spyOn(Date.prototype, 'getTime').and.returnValue(now);
spyOn(service, 'getByUUID').and.returnValue(observableOf({
const requestEntry = {
completed: true,
response: {
isSuccessful: true,
timeAdded: timeAdded
@@ -552,14 +493,50 @@ describe('RequestService', () => {
request: {
responseMsToLive: msToLive
}
}));
const uuid = 'f9b85788-881c-4994-86b6-bae8dad024d2';
reusable = serviceAsAny.isReusable(uuid);
};
beforeEach(() => {
spyOn(Date.prototype, 'getTime').and.returnValue(now);
spyOn(service, 'getByUUID').and.returnValue(observableOf(requestEntry));
valid = serviceAsAny.isValid(requestEntry);
});
it('return an observable emitting true', () => {
reusable.subscribe((isReusable) => expect(isReusable).toBe(true));
})
expect(valid).toBe(true);
})
})
});
describe('hasByHref', () => {
describe('when nothing is returned by getByHref', () => {
beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(EMPTY);
});
it('hasByHref should return false', () => {
const result = service.hasByHref('');
expect(result).toBe(false);
});
});
describe('when isValid returns false', () => {
beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(observableOf(undefined));
spyOn(service as any, 'isValid').and.returnValue(false);
});
it('hasByHref should return false', () => {
const result = service.hasByHref('');
expect(result).toBe(false);
});
});
describe('when isValid returns true', () => {
beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(observableOf(undefined));
spyOn(service as any, 'isValid').and.returnValue(true);
});
it('hasByHref should return true', () => {
const result = service.hasByHref('');
expect(result).toBe(true);
});
});
});
});

View File

@@ -5,43 +5,48 @@ import { merge as observableMerge, Observable, of as observableOf, race as obser
import { filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators';
import { remove } from 'lodash';
import { AppState } from '../../app.reducer';
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { CacheableObject } from '../cache/object-cache.reducer';
import { ObjectCacheService } from '../cache/object-cache.service';
import { DSOSuccessResponse, RestResponse } from '../cache/response.models';
import { coreSelector, CoreState } from '../core.reducers';
import { IndexName, IndexState } from '../index/index.reducer';
import { pathSelector } from '../shared/selectors';
import { CoreState } from '../core.reducers';
import { IndexName, IndexState, MetaIndexState } from '../index/index.reducer';
import {
originalRequestUUIDFromRequestUUIDSelector,
requestIndexSelector,
uuidFromHrefSelector
} from '../index/index.selectors';
import { UUIDService } from '../shared/uuid.service';
import { RequestConfigureAction, RequestExecuteAction, RequestRemoveAction } from './request.actions';
import {
RequestConfigureAction,
RequestExecuteAction,
RequestRemoveAction
} from './request.actions';
import { GetRequest, RestRequest } from './request.models';
import { RequestEntry } from './request.reducer';
import { RequestEntry, RequestState } from './request.reducer';
import { CommitSSBAction } from '../cache/server-sync-buffer.actions';
import { RestRequestMethod } from './rest-request-method';
import { getResponseFromEntry } from '../shared/operators';
import { AddToIndexAction, RemoveFromIndexBySubstringAction } from '../index/index.actions';
import { coreSelector } from '../core.selectors';
@Injectable()
export class RequestService {
private requestsOnTheirWayToTheStore: string[] = [];
/**
* The base selector function to select the request state in the store
*/
const requestCacheSelector = createSelector(
coreSelector,
(state: CoreState) => state['data/request']
);
constructor(private objectCache: ObjectCacheService,
private uuidService: UUIDService,
private store: Store<CoreState>,
private indexStore: Store<IndexState>) {
}
private entryFromUUIDSelector(uuid: string): MemoizedSelector<CoreState, RequestEntry> {
return pathSelector<CoreState, RequestEntry>(coreSelector, 'data/request', uuid);
}
private uuidFromHrefSelector(href: string): MemoizedSelector<CoreState, string> {
return pathSelector<CoreState, string>(coreSelector, 'index', IndexName.REQUEST, href);
}
private originalUUIDFromUUIDSelector(uuid: string): MemoizedSelector<CoreState, string> {
return pathSelector<CoreState, string>(coreSelector, 'index', IndexName.UUID_MAPPING, uuid);
/**
* Selector function to select a request entry by uuid from the cache
* @param uuid The uuid of the request
*/
const entryFromUUIDSelector = (uuid: string): MemoizedSelector<CoreState, RequestEntry> => createSelector(
requestCacheSelector,
(state: RequestState) => {
return hasValue(state) ? state[uuid] : undefined;
}
);
/**
* Create a selector that fetches a list of request UUIDs from a given index substate of which the request href
@@ -50,35 +55,46 @@ export class RequestService {
* @param name The name of the index substate we're fetching request UUIDs from
* @param href Substring that the request's href should contain
*/
private uuidsFromHrefSubstringSelector(selector: MemoizedSelector<any, IndexState>, name: string, href: string): MemoizedSelector<any, string[]> {
return createSelector(selector, (state: IndexState) => this.getUuidsFromHrefSubstring(state, name, href));
}
const uuidsFromHrefSubstringSelector =
(selector: MemoizedSelector<AppState, IndexState>, href: string): MemoizedSelector<AppState, string[]> => createSelector(
selector,
(state: IndexState) => getUuidsFromHrefSubstring(state, href)
);
/**
* Fetch a list of request UUIDs from a given index substate of which the request href contains a given substring
* @param state The IndexState
* @param name The name of the index substate we're fetching request UUIDs from
* @param href Substring that the request's href should contain
*/
private getUuidsFromHrefSubstring(state: IndexState, name: string, href: string): string[] {
const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => {
let result = [];
if (isNotEmpty(state)) {
const subState = state[name];
if (isNotEmpty(subState)) {
for (const value in subState) {
if (value.indexOf(href) > -1) {
result = [...result, subState[value]];
}
}
}
result = Object.values(state)
.filter((value: string) => value.startsWith(href));
}
return result;
};
/**
* A service to interact with the request state in the store
*/
@Injectable()
export class RequestService {
private requestsOnTheirWayToTheStore: string[] = [];
constructor(private objectCache: ObjectCacheService,
private uuidService: UUIDService,
private store: Store<CoreState>,
private indexStore: Store<MetaIndexState>) {
}
generateRequestId(): string {
return `client/${this.uuidService.generate()}`;
}
/**
* Check if a request is currently pending
*/
isPending(request: GetRequest): boolean {
// first check requests that haven't made it to the store yet
if (this.requestsOnTheirWayToTheStore.includes(request.href)) {
@@ -92,25 +108,30 @@ export class RequestService {
.subscribe((re: RequestEntry) => {
isPending = (hasValue(re) && !re.completed)
});
return isPending;
}
/**
* Retrieve a RequestEntry based on their uuid
*/
getByUUID(uuid: string): Observable<RequestEntry> {
return observableRace(
this.store.pipe(select(this.entryFromUUIDSelector(uuid))),
this.store.pipe(select(entryFromUUIDSelector(uuid))),
this.store.pipe(
select(this.originalUUIDFromUUIDSelector(uuid)),
select(originalRequestUUIDFromRequestUUIDSelector(uuid)),
mergeMap((originalUUID) => {
return this.store.pipe(select(this.entryFromUUIDSelector(originalUUID)))
return this.store.pipe(select(entryFromUUIDSelector(originalUUID)))
},
))
);
}
/**
* Retrieve a RequestEntry based on their href
*/
getByHref(href: string): Observable<RequestEntry> {
return this.store.pipe(
select(this.uuidFromHrefSelector(href)),
select(uuidFromHrefSelector(href)),
mergeMap((uuid: string) => this.getByUUID(uuid))
);
}
@@ -173,7 +194,7 @@ export class RequestService {
*/
removeByHrefSubstring(href: string) {
this.store.pipe(
select(this.uuidsFromHrefSubstringSelector(pathSelector<CoreState, IndexState>(coreSelector, 'index'), IndexName.REQUEST, href)),
select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)),
take(1)
).subscribe((uuids: string[]) => {
for (const uuid of uuids) {
@@ -197,31 +218,11 @@ export class RequestService {
* @param {GetRequest} request The request to check
* @returns {boolean} True if the request is cached or still pending
*/
private isCachedOrPending(request: GetRequest) {
let isCached = this.objectCache.hasBySelfLink(request.href);
if (isCached) {
const responses: Observable<RestResponse> = this.isReusable(request.uuid).pipe(
filter((reusable: boolean) => reusable),
switchMap(() => {
return this.getByHref(request.href).pipe(
getResponseFromEntry(),
take(1)
);
}
));
private isCachedOrPending(request: GetRequest): boolean {
const inReqCache = this.hasByHref(request.href);
const inObjCache = this.objectCache.hasBySelfLink(request.href);
const isCached = inReqCache || inObjCache;
const errorResponses = responses.pipe(filter((response) => !response.isSuccessful), map(() => true)); // TODO add a configurable number of retries in case of an error.
const dsoSuccessResponses = responses.pipe(
filter((response) => response.isSuccessful && hasValue((response as DSOSuccessResponse).resourceSelfLinks)),
map((response: DSOSuccessResponse) => response.resourceSelfLinks),
map((resourceSelfLinks: string[]) => resourceSelfLinks
.every((selfLink) => this.objectCache.hasBySelfLink(selfLink))
));
const otherSuccessResponses = responses.pipe(filter((response) => response.isSuccessful && !hasValue((response as DSOSuccessResponse).resourceSelfLinks)), map(() => true));
observableMerge(errorResponses, otherSuccessResponses, dsoSuccessResponses).subscribe((c) => isCached = c);
}
const isPending = this.isPending(request);
return isCached || isPending;
}
@@ -244,7 +245,7 @@ export class RequestService {
*/
private trackRequestsOnTheirWayToTheStore(request: GetRequest) {
this.requestsOnTheirWayToTheStore = [...this.requestsOnTheirWayToTheStore, request.href];
this.store.pipe(select(this.entryFromUUIDSelector(request.href)),
this.getByHref(request.href).pipe(
filter((re: RequestEntry) => hasValue(re)),
take(1)
).subscribe((re: RequestEntry) => {
@@ -274,31 +275,39 @@ export class RequestService {
}
/**
* Check whether a Response should still be cached
* Check whether a cached response should still be valid
*
* @param uuid
* the uuid of the entry to check
* @param entry
* the entry to check
* @return boolean
* false if the uuid has no value, no entry could be found, the response was nog successful or its time to
* live has exceeded, true otherwise
* false if the uuid has no value, the response was not successful or its time to
* live was exceeded, true otherwise
*/
private isReusable(uuid: string): Observable<boolean> {
if (hasNoValue(uuid)) {
return observableOf(false);
} else {
const requestEntry$ = this.getByUUID(uuid);
return requestEntry$.pipe(
filter((entry: RequestEntry) => hasValue(entry) && hasValue(entry.response)),
map((entry: RequestEntry) => {
if (hasValue(entry) && entry.response.isSuccessful) {
private isValid(entry: RequestEntry): boolean {
if (hasValue(entry) && entry.completed && entry.response.isSuccessful) {
const timeOutdated = entry.response.timeAdded + entry.request.responseMsToLive;
const isOutDated = new Date().getTime() > timeOutdated;
return !isOutDated;
} else {
return false;
}
})
);
}
/**
* Check whether the request with the specified href is cached
*
* @param href
* The link of the request to check
* @return boolean
* true if the request with the specified href is cached,
* false otherwise
*/
hasByHref(href: string): boolean {
let result = false;
this.getByHref(href).pipe(
take(1)
).subscribe((requestEntry: RequestEntry) => result = this.isValid(requestEntry));
return result;
}
}

View File

@@ -1,6 +1,6 @@
import * as deepFreeze from 'deep-freeze';
import { IndexName, indexReducer, IndexState } from './index.reducer';
import { IndexName, indexReducer, MetaIndexState } from './index.reducer';
import { AddToIndexAction, RemoveFromIndexBySubstringAction, RemoveFromIndexByValueAction } from './index.actions';
class NullAction extends AddToIndexAction {
@@ -17,7 +17,7 @@ describe('requestReducer', () => {
const key2 = '1911e8a4-6939-490c-b58b-a5d70f8d91fb';
const val1 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/567a639f-f5ff-4126-807c-b7d0910808c8';
const val2 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/1911e8a4-6939-490c-b58b-a5d70f8d91fb';
const testState: IndexState = {
const testState: MetaIndexState = {
[IndexName.OBJECT]: {
[key1]: val1
},[IndexName.REQUEST]: {

View File

@@ -1,26 +1,57 @@
import {
AddToIndexAction,
IndexAction,
IndexActionTypes,
AddToIndexAction,
RemoveFromIndexByValueAction, RemoveFromIndexBySubstringAction
RemoveFromIndexBySubstringAction,
RemoveFromIndexByValueAction
} from './index.actions';
/**
* An enum containing all index names
*/
export enum IndexName {
// Contains all objects in the object cache indexed by UUID
OBJECT = 'object/uuid-to-self-link',
// contains all requests in the request cache indexed by UUID
REQUEST = 'get-request/href-to-uuid',
/**
* Contains the UUIDs of requests that were sent to the server and
* have their responses cached, indexed by the UUIDs of requests that
* weren't sent because the response they requested was already cached
*/
UUID_MAPPING = 'get-request/configured-to-cache-uuid'
}
export type IndexState = {
[name in IndexName]: {
/**
* The state of a single index
*/
export interface IndexState {
[key: string]: string
}
/**
* The state that contains all indices
*/
export type MetaIndexState = {
[name in IndexName]: IndexState
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState: IndexState = Object.create(null);
const initialState: MetaIndexState = Object.create(null);
export function indexReducer(state = initialState, action: IndexAction): IndexState {
/**
* The Index Reducer
*
* @param state
* the current state
* @param action
* the action to perform on the state
* @return MetaIndexState
* the new state
*/
export function indexReducer(state = initialState, action: IndexAction): MetaIndexState {
switch (action.type) {
case IndexActionTypes.ADD: {
@@ -41,7 +72,17 @@ export function indexReducer(state = initialState, action: IndexAction): IndexSt
}
}
function addToIndex(state: IndexState, action: AddToIndexAction): IndexState {
/**
* Add an entry to a given index
*
* @param state
* The MetaIndexState that contains all indices
* @param action
* The AddToIndexAction containing the value to add, and the index to add it to
* @return MetaIndexState
* the new state
*/
function addToIndex(state: MetaIndexState, action: AddToIndexAction): MetaIndexState {
const subState = state[action.payload.name];
const newSubState = Object.assign({}, subState, {
[action.payload.key]: action.payload.value
@@ -52,7 +93,17 @@ function addToIndex(state: IndexState, action: AddToIndexAction): IndexState {
return obs;
}
function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValueAction): IndexState {
/**
* Remove a entries that contain a given value from a given index
*
* @param state
* The MetaIndexState that contains all indices
* @param action
* The RemoveFromIndexByValueAction containing the value to remove, and the index to remove it from
* @return MetaIndexState
* the new state
*/
function removeFromIndexByValue(state: MetaIndexState, action: RemoveFromIndexByValueAction): MetaIndexState {
const subState = state[action.payload.name];
const newSubState = Object.create(null);
for (const value in subState) {
@@ -66,11 +117,16 @@ function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValu
}
/**
* Remove values from the IndexState's substate that contain a given substring
* @param state The IndexState to remove values from
* @param action The RemoveFromIndexByValueAction containing the necessary information to remove the values
* Remove entries that contain a given substring from a given index
*
* @param state
* The MetaIndexState that contains all indices
* @param action
* The RemoveFromIndexByValueAction the substring to remove, and the index to remove it from
* @return MetaIndexState
* the new state
*/
function removeFromIndexBySubstring(state: IndexState, action: RemoveFromIndexByValueAction): IndexState {
function removeFromIndexBySubstring(state: MetaIndexState, action: RemoveFromIndexByValueAction): MetaIndexState {
const subState = state[action.payload.name];
const newSubState = Object.create(null);
for (const value in subState) {

View File

@@ -0,0 +1,94 @@
import { createSelector, MemoizedSelector } from '@ngrx/store';
import { AppState } from '../../app.reducer';
import { hasValue } from '../../shared/empty.util';
import { CoreState } from '../core.reducers';
import { coreSelector } from '../core.selectors';
import { IndexName, IndexState, MetaIndexState } from './index.reducer';
/**
* Return the MetaIndexState based on the CoreSate
*
* @returns
* a MemoizedSelector to select the MetaIndexState
*/
export const metaIndexSelector: MemoizedSelector<AppState, MetaIndexState> = createSelector(
coreSelector,
(state: CoreState) => state.index
);
/**
* Return the object index based on the MetaIndexState
* It contains all objects in the object cache indexed by UUID
*
* @returns
* a MemoizedSelector to select the object index
*/
export const objectIndexSelector: MemoizedSelector<AppState, IndexState> = createSelector(
metaIndexSelector,
(state: MetaIndexState) => state[IndexName.OBJECT]
);
/**
* Return the request index based on the MetaIndexState
*
* @returns
* a MemoizedSelector to select the request index
*/
export const requestIndexSelector: MemoizedSelector<AppState, IndexState> = createSelector(
metaIndexSelector,
(state: MetaIndexState) => state[IndexName.REQUEST]
);
/**
* Return the request UUID mapping index based on the MetaIndexState
*
* @returns
* a MemoizedSelector to select the request UUID mapping
*/
export const requestUUIDIndexSelector: MemoizedSelector<AppState, IndexState> = createSelector(
metaIndexSelector,
(state: MetaIndexState) => state[IndexName.UUID_MAPPING]
);
/**
* Return the self link of an object in the object-cache based on its UUID
*
* @param uuid
* the UUID for which you want to find the matching self link
* @returns
* a MemoizedSelector to select the self link
*/
export const selfLinkFromUuidSelector =
(uuid: string): MemoizedSelector<AppState, string> => createSelector(
objectIndexSelector,
(state: IndexState) => hasValue(state) ? state[uuid] : undefined
);
/**
* Return the UUID of a GET request based on its href
*
* @param href
* the href of the GET request
* @returns
* a MemoizedSelector to select the UUID
*/
export const uuidFromHrefSelector =
(href: string): MemoizedSelector<AppState, string> => createSelector(
requestIndexSelector,
(state: IndexState) => hasValue(state) ? state[href] : undefined
);
/**
* Return the UUID of a cached request based on the UUID of a request
* that wasn't sent because the response was already cached
*
* @param uuid
* The UUID of the new request
* @returns
* a MemoizedSelector to select the UUID of the cached request
*/
export const originalRequestUUIDFromRequestUUIDSelector =
(uuid: string): MemoizedSelector<AppState, string> => createSelector(
requestUUIDIndexSelector,
(state: IndexState) => hasValue(state) ? state[uuid] : undefined
);

View File

@@ -1,17 +0,0 @@
import { createSelector, MemoizedSelector } from '@ngrx/store';
import { hasNoValue, isEmpty } from '../../shared/empty.util';
export function pathSelector<From, To>(selector: MemoizedSelector<any, From>, ...path: string[]): MemoizedSelector<any, To> {
return createSelector(selector, (state: any) => getSubState(state, path));
}
function getSubState(state: any, path: string[]) {
const current = path[0];
const remainingPath = path.slice(1);
const subState = state[current];
if (hasNoValue(subState) || isEmpty(remainingPath)) {
return subState;
} else {
return getSubState(subState, remainingPath);
}
}

View File

@@ -0,0 +1,20 @@
<div class="form-group w-100 pr-2 pl-2">
<input type="search"
class="form-control"
(click)="$event.stopPropagation();"
placeholder="{{'dso-selector.placeholder' | translate: { type: type.toString().toLowerCase() } }}"
[formControl]="input" dsAutoFocus (keyup.enter)="selectSingleResult()">
</div>
<div class="dropdown-divider"></div>
<div class="scrollable-menu list-group">
<button class="list-group-item list-group-item-action border-0 disabled"
*ngIf="(listEntries$ | async)?.payload.page.length == 0">
{{'dso-selector.no-results' | translate: { type: type.toString().toLowerCase() } }}
</button>
<button *ngFor="let listEntry of (listEntries$ | async)?.payload.page"
class="list-group-item list-group-item-action border-0 list-entry"
title="{{ listEntry.dspaceObject.name }}"
(click)="onSelect.emit(listEntry.dspaceObject)" #listEntryElement>
<ds-wrapper-list-element [object]="listEntry"></ds-wrapper-list-element>
</button>
</div>

View File

@@ -0,0 +1,73 @@
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { DSOSelectorComponent } from './dso-selector.component';
import { SearchService } from '../../../+search-page/search-service/search.service';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model';
import { RemoteData } from '../../../core/data/remote-data';
import { ItemSearchResult } from '../../object-collection/shared/item-search-result.model';
import { Item } from '../../../core/shared/item.model';
import { of as observableOf } from 'rxjs';
import { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataValue } from '../../../core/shared/metadata.models';
describe('DSOSelectorComponent', () => {
let component: DSOSelectorComponent;
let fixture: ComponentFixture<DSOSelectorComponent>;
let debugElement: DebugElement;
const currentDSOId = 'test-uuid-ford-sose';
const type = DSpaceObjectType.ITEM;
const searchResult = new ItemSearchResult();
const item = new Item();
item.metadata = {
'dc.title': [Object.assign(new MetadataValue(), {
value: 'Item title',
language: undefined
})]
};
searchResult.dspaceObject = item;
searchResult.hitHighlights = {};
const searchService = jasmine.createSpyObj('searchService', {
search: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(undefined, [searchResult])))
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [DSOSelectorComponent],
providers: [
{ provide: SearchService, useValue: searchService },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DSOSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
component.currentDSOId = currentDSOId;
component.type = type;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initially call the search method on the SearchService with the given DSO uuid', () => {
const searchOptions = new PaginatedSearchOptions({
query: currentDSOId,
dsoType: type,
pagination: (component as any).defaultPagination
});
expect(searchService.search).toHaveBeenCalledWith(searchOptions);
});
})
;

View File

@@ -0,0 +1,109 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
QueryList,
ViewChildren
} from '@angular/core';
import { FormControl } from '@angular/forms';
import { Observable } from 'rxjs';
import { debounceTime, startWith, switchMap } from 'rxjs/operators';
import { SearchService } from '../../../+search-page/search-service/search.service';
import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { SearchResult } from '../../../+search-page/search-result.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
@Component({
selector: 'ds-dso-selector',
// styleUrls: ['./dso-selector.component.scss'],
templateUrl: './dso-selector.component.html'
})
/**
* Component to render a list of DSO's of which one can be selected
* The user can search the list by using the input field
*/
export class DSOSelectorComponent implements OnInit {
/**
* The initially selected DSO's uuid
*/
@Input() currentDSOId: string;
/**
* The type of DSpace objects this components shows a list of
*/
@Input() type: DSpaceObjectType;
/**
* Emits the selected Object when a user selects it in the list
*/
@Output() onSelect: EventEmitter<DSpaceObject> = new EventEmitter();
/**
* Input form control to query the list
*/
public input: FormControl = new FormControl();
/**
* Default pagination for this feature
*/
private defaultPagination = { id: 'dso-selector', currentPage: 1, pageSize: 5 } as any;
/**
* List with search results of DSpace objects for the current query
*/
listEntries$: Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>;
/**
* List of element references to all elements
*/
@ViewChildren('listEntryElement') listElements: QueryList<ElementRef>;
/**
* Time to wait before sending a search request to the server when a user types something
*/
debounceTime = 500;
constructor(private searchService: SearchService) {
}
/**
* Fills the listEntries$ variable with search results based on the input field's current value
* The search will always start with the initial currentDSOId value
*/
ngOnInit(): void {
this.input.setValue(this.currentDSOId);
this.listEntries$ = this.input.valueChanges
.pipe(
debounceTime(this.debounceTime),
startWith(this.currentDSOId),
switchMap((query) => {
return this.searchService.search(
new PaginatedSearchOptions({
query: query,
dsoType: this.type,
pagination: this.defaultPagination
})
)
}
)
)
}
/**
* Set focus on the first list element when there is only one result
*/
selectSingleResult(): void {
if (this.listElements.length > 0) {
this.listElements.first.nativeElement.click();
}
}
}

View File

@@ -0,0 +1,72 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../../core/data/remote-data';
import { RouterStub } from '../../../testing/router-stub';
import * as collectionRouter from '../../../../+collection-page/collection-page-routing.module';
import { Community } from '../../../../core/shared/community.model';
import { CreateCollectionParentSelectorComponent } from './create-collection-parent-selector.component';
import { MetadataValue } from '../../../../core/shared/metadata.models';
describe('CreateCollectionParentSelectorComponent', () => {
let component: CreateCollectionParentSelectorComponent;
let fixture: ComponentFixture<CreateCollectionParentSelectorComponent>;
let debugElement: DebugElement;
const community = new Community();
community.uuid = '1234-1234-1234-1234';
community.metadata = {
'dc.title': [
Object.assign(new MetadataValue(), {
value: 'Community title',
language: undefined
})]
};
const router = new RouterStub();
const communityRD = new RemoteData(false, false, true, undefined, community);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const createPath = 'testCreatePath';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [CreateCollectionParentSelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ community: communityRD }) } } } }
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
spyOnProperty(collectionRouter, 'getCollectionCreatePath').and.callFake(() => {
return () => createPath;
});
fixture = TestBed.createComponent(CreateCollectionParentSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct edit path when navigate is called', () => {
component.navigate(community);
expect(router.navigate).toHaveBeenCalledWith([createPath], { queryParams: { parent: community.uuid } });
});
});

View File

@@ -0,0 +1,46 @@
import { Component, Input, OnInit } from '@angular/core';
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router';
import { Community } from '../../../../core/shared/community.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import {
COLLECTION_PARENT_PARAMETER,
getCollectionCreatePath
} from '../../../../+collection-page/collection-page-routing.module';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from '../dso-selector-modal-wrapper.component';
/**
* Component to wrap a list of existing communities inside a modal
* Used to choose a community from to create a new collection in
*/
@Component({
selector: 'ds-create-collection-parent-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
})
export class CreateCollectionParentSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.COLLECTION;
selectorType = DSpaceObjectType.COMMUNITY;
action = SelectorActionType.CREATE;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route);
}
/**
* Navigate to the collection create page
*/
navigate(dso: DSpaceObject) {
const navigationExtras: NavigationExtras = {
queryParams: {
[COLLECTION_PARENT_PARAMETER]: dso.uuid,
}
};
this.router.navigate([getCollectionCreatePath()], navigationExtras);
}
}

View File

@@ -0,0 +1,19 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="close" (click)="close()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<button class="btn btn-outline-primary btn-lg btn-block" (click)="selectObject(undefined)">{{'dso-selector.create.community.top-level' | translate}}</button>
<h3 class="position-relative py-1 my-3 font-weight-normal">
<hr>
<div id="create-community-or-separator" class="text-center position-absolute w-100">
<span class="px-4 bg-white">or</span>
</div>
</h3>
<h5 class="px-2">{{'dso-selector.create.community.sub-level' | translate}}</h5>
<ds-dso-selector [currentDSOId]="(dsoRD$ | async)?.payload.uuid" [type]="selectorType" (onSelect)="selectObject($event)"></ds-dso-selector>
</div>
</div>

View File

@@ -0,0 +1,3 @@
#create-community-or-separator {
top: 0;
}

View File

@@ -0,0 +1,66 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../../core/data/remote-data';
import { RouterStub } from '../../../testing/router-stub';
import * as communityRouter from '../../../../+community-page/community-page-routing.module';
import { Community } from '../../../../core/shared/community.model';
import { CreateCommunityParentSelectorComponent } from './create-community-parent-selector.component';
import { MetadataValue } from '../../../../core/shared/metadata.models';
describe('CreateCommunityParentSelectorComponent', () => {
let component: CreateCommunityParentSelectorComponent;
let fixture: ComponentFixture<CreateCommunityParentSelectorComponent>;
let debugElement: DebugElement;
const community = new Community();
community.uuid = '1234-1234-1234-1234';
community.metadata = { 'dc.title': [Object.assign(new MetadataValue(), { value: 'Community title', language: undefined })] };
const router = new RouterStub();
const communityRD = new RemoteData(false, false, true, undefined, community);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const createPath = 'testCreatePath';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [CreateCommunityParentSelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ community: communityRD }) } } } }
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
spyOnProperty(communityRouter, 'getCommunityCreatePath').and.callFake(() => {
return () => createPath;
});
fixture = TestBed.createComponent(CreateCommunityParentSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct edit path when navigate is called', () => {
component.navigate(community);
expect(router.navigate).toHaveBeenCalledWith([createPath], { queryParams: { parent: community.uuid } });
});
});

View File

@@ -0,0 +1,51 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { hasValue } from '../../../empty.util';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import {
COMMUNITY_PARENT_PARAMETER,
getCommunityCreatePath
} from '../../../../+community-page/community-page-routing.module';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from '../dso-selector-modal-wrapper.component';
/**
* Component to wrap a button - for top communities -
* and a list of parent communities - for sub communities
* inside a modal
* Used to create a new community
*/
@Component({
selector: 'ds-create-community-parent-selector',
styleUrls: ['./create-community-parent-selector.component.scss'],
templateUrl: './create-community-parent-selector.component.html',
})
export class CreateCommunityParentSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.COMMUNITY;
selectorType = DSpaceObjectType.COMMUNITY;
action = SelectorActionType.CREATE;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route);
}
/**
* Navigate to the community create page
*/
navigate(dso: DSpaceObject) {
let navigationExtras: NavigationExtras = {};
if (hasValue(dso)) {
navigationExtras = {
queryParams: {
[COMMUNITY_PARENT_PARAMETER]: dso.uuid,
}
};
}
this.router.navigate([getCommunityCreatePath()], navigationExtras);
}
}

View File

@@ -0,0 +1,66 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../../core/data/remote-data';
import { RouterStub } from '../../../testing/router-stub';
import { Collection } from '../../../../core/shared/collection.model';
import { CreateItemParentSelectorComponent } from './create-item-parent-selector.component';
import { MetadataValue } from '../../../../core/shared/metadata.models';
describe('CreateItemParentSelectorComponent', () => {
let component: CreateItemParentSelectorComponent;
let fixture: ComponentFixture<CreateItemParentSelectorComponent>;
let debugElement: DebugElement;
const collection = new Collection();
collection.uuid = '1234-1234-1234-1234';
collection.metadata = { 'dc.title': [Object.assign(new MetadataValue(), { value: 'Collection title', language: undefined })] };
const router = new RouterStub();
const collectionRD = new RemoteData(false, false, true, undefined, collection);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const createPath = 'testCreatePath';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [CreateItemParentSelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ collection: collectionRD }) } } } }
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
// spyOnProperty(itemRouter, 'getItemCreatePath').and.callFake(() => {
// return () => createPath;
// });
fixture = TestBed.createComponent(CreateItemParentSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct create path when navigate is called', () => {
/* TODO when there is a specific submission path */
// component.navigate(item);
// expect(router.navigate).toHaveBeenCalledWith([createPath]);
});
});

View File

@@ -0,0 +1,42 @@
import { Component, Input, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Community } from '../../../../core/shared/community.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { Collection } from '../../../../core/shared/collection.model';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { hasValue } from '../../../empty.util';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from '../dso-selector-modal-wrapper.component';
/**
* Component to wrap a list of existing collections inside a modal
* Used to choose a collection from to create a new item in
*/
@Component({
selector: 'ds-create-item-parent-selector',
// styleUrls: ['./create-item-parent-selector.component.scss'],
templateUrl: '../dso-selector-modal-wrapper.component.html',
})
export class CreateItemParentSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.ITEM;
selectorType = DSpaceObjectType.COLLECTION;
action = SelectorActionType.CREATE;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route);
}
/**
* Navigate to the item create page
*/
navigate(dso: DSpaceObject) {
// There's no submit path per collection yet...
}
}

View File

@@ -0,0 +1,10 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="close" (click)="close()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<ds-dso-selector [currentDSOId]="(dsoRD$ | async)?.payload.uuid" [type]="selectorType" (onSelect)="selectObject($event)"></ds-dso-selector>
</div>
</div>

View File

@@ -0,0 +1,135 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { Component, DebugElement, NO_ERRORS_SCHEMA, OnInit } from '@angular/core';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model';
import { of as observableOf } from 'rxjs';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from './dso-selector-modal-wrapper.component';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute } from '@angular/router';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { first } from 'rxjs/operators';
import { By } from '@angular/platform-browser';
import { DSOSelectorComponent } from '../dso-selector/dso-selector.component';
import { MockComponent } from 'ng-mocks';
import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.models';
describe('DSOSelectorModalWrapperComponent', () => {
let component: DSOSelectorModalWrapperComponent;
let fixture: ComponentFixture<DSOSelectorModalWrapperComponent>;
let debugElement: DebugElement;
const item = new Item();
item.uuid = '1234-1234-1234-1234';
item.metadata = {
'dc.title': [Object.assign(new MetadataValue(), {
value: 'Item title',
language: undefined
})]
};
const itemRD = new RemoteData(false, false, true, undefined, item);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [TestComponent, MockComponent(DSOSelectorComponent)],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ item: itemRD }) } } } }
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
component.ngOnInit();
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initially set the DSO to the activated route\'s item/collection/community', () => {
component.dsoRD$
.pipe(first())
.subscribe((a) => {
expect(a).toEqual(itemRD);
})
});
describe('selectObject', () => {
beforeEach(() => {
spyOn(component, 'navigate');
spyOn(component, 'close');
component.selectObject(item)
});
it('should call the close and navigate method on the component with the given DSO', () => {
expect(component.close).toHaveBeenCalled();
expect(component.navigate).toHaveBeenCalledWith(item);
});
});
describe('close', () => {
beforeEach(() => {
component.close();
});
it('should call the close method on the æctive modal', () => {
expect(modalStub.close).toHaveBeenCalled();
});
});
describe('when the onSelect method emits on the child component', () => {
beforeEach(() => {
spyOn(component, 'selectObject');
debugElement.query(By.css('ds-dso-selector')).componentInstance.onSelect.emit(item);
fixture.detectChanges();
});
it('should call the selectObject method on the component with the correct object', () => {
expect(component.selectObject).toHaveBeenCalledWith(item);
});
});
describe('when the click method emits on close button', () => {
beforeEach(() => {
spyOn(component, 'close');
debugElement.query(By.css('button.close')).triggerEventHandler('click', {});
fixture.detectChanges();
});
it('should call the close method on the component', () => {
expect(component.close).toHaveBeenCalled();
});
});
});
@Component({
selector: 'ds-test-cmp',
templateUrl: './dso-selector-modal-wrapper.component.html'
})
class TestComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.ITEM;
selectorType = DSpaceObjectType.ITEM;
action = SelectorActionType.EDIT;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute) {
super(activeModal, route);
}
navigate(dso: DSpaceObject) {
/* comment */
}
}

View File

@@ -0,0 +1,73 @@
import { Component, Injectable, Input, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { RemoteData } from '../../../core/data/remote-data';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { map } from 'rxjs/operators';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
export enum SelectorActionType {
CREATE = 'create',
EDIT = 'edit'
}
/**
* Abstract base class that represents a wrapper for modal content used to select a DSpace Object
*/
@Injectable()
export abstract class DSOSelectorModalWrapperComponent implements OnInit {
/**
* The current page's DSO
*/
@Input() dsoRD$: Observable<RemoteData<DSpaceObject>>;
/**
* The type of the DSO that's being edited or created
*/
objectType: DSpaceObjectType;
/**
* The type of DSO that can be selected from this list
*/
selectorType: DSpaceObjectType;
/**
* The type of action to perform
*/
action: SelectorActionType;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute) {
}
/**
* Get de current page's DSO based on the selectorType
*/
ngOnInit(): void {
const typeString = this.selectorType.toString().toLowerCase();
this.dsoRD$ = this.route.root.firstChild.firstChild.data.pipe(map((data) => data[typeString]));
}
/**
* Method called when an object has been selected
* @param dso The selected DSpaceObject
*/
selectObject(dso: DSpaceObject) {
this.close();
this.navigate(dso);
}
/**
* Navigate to a page based on the DSpaceObject provided
* @param dso The DSpaceObject which can be used to calculate the page to navigate to
*/
abstract navigate(dso: DSpaceObject);
/**
* Close the modal
*/
close() {
this.activeModal.close();
}
}

View File

@@ -0,0 +1,66 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../../core/data/remote-data';
import { RouterStub } from '../../../testing/router-stub';
import * as collectionRouter from '../../../../+collection-page/collection-page-routing.module';
import { EditCollectionSelectorComponent } from './edit-collection-selector.component';
import { Collection } from '../../../../core/shared/collection.model';
import { MetadataValue } from '../../../../core/shared/metadata.models';
describe('EditCollectionSelectorComponent', () => {
let component: EditCollectionSelectorComponent;
let fixture: ComponentFixture<EditCollectionSelectorComponent>;
let debugElement: DebugElement;
const collection = new Collection();
collection.uuid = '1234-1234-1234-1234';
collection.metadata = { 'dc.title': [Object.assign(new MetadataValue(), { value: 'Collection title', language: undefined })] };
const router = new RouterStub();
const collectionRD = new RemoteData(false, false, true, undefined, collection);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const editPath = 'testEditPath';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [EditCollectionSelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ collection: collectionRD }) } } } }
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
spyOnProperty(collectionRouter, 'getCollectionEditPath').and.callFake(() => {
return () => editPath;
});
fixture = TestBed.createComponent(EditCollectionSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct edit path when navigate is called', () => {
component.navigate(collection);
expect(router.navigate).toHaveBeenCalledWith([editPath]);
});
});

View File

@@ -0,0 +1,36 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { getCollectionEditPath } from '../../../../+collection-page/collection-page-routing.module';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from '../dso-selector-modal-wrapper.component';
/**
* Component to wrap a list of existing collections inside a modal
* Used to choose a collection from to edit
*/
@Component({
selector: 'ds-edit-collection-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
})
export class EditCollectionSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.COLLECTION;
selectorType = DSpaceObjectType.COLLECTION;
action = SelectorActionType.EDIT;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route);
}
/**
* Navigate to the collection edit page
*/
navigate(dso: DSpaceObject) {
this.router.navigate([getCollectionEditPath(dso.uuid)]);
}
}

View File

@@ -0,0 +1,66 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../../core/data/remote-data';
import { RouterStub } from '../../../testing/router-stub';
import * as communityRouter from '../../../../+community-page/community-page-routing.module';
import { EditCommunitySelectorComponent } from './edit-community-selector.component';
import { Community } from '../../../../core/shared/community.model';
import { MetadataValue } from '../../../../core/shared/metadata.models';
describe('EditCommunitySelectorComponent', () => {
let component: EditCommunitySelectorComponent;
let fixture: ComponentFixture<EditCommunitySelectorComponent>;
let debugElement: DebugElement;
const community = new Community();
community.uuid = '1234-1234-1234-1234';
community.metadata = { 'dc.title': [Object.assign(new MetadataValue(), { value: 'Community title', language: undefined })] };
const router = new RouterStub();
const communityRD = new RemoteData(false, false, true, undefined, community);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const editPath = 'testEditPath';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [EditCommunitySelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ community: communityRD }) } } } }
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
spyOnProperty(communityRouter, 'getCommunityEditPath').and.callFake(() => {
return () => editPath;
});
fixture = TestBed.createComponent(EditCommunitySelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct edit path when navigate is called', () => {
component.navigate(community);
expect(router.navigate).toHaveBeenCalledWith([editPath]);
});
});

View File

@@ -0,0 +1,37 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { getCommunityEditPath } from '../../../../+community-page/community-page-routing.module';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from '../dso-selector-modal-wrapper.component';
/**
* Component to wrap a list of existing communities inside a modal
* Used to choose a community from to edit
*/
@Component({
selector: 'ds-edit-community-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
})
export class EditCommunitySelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.COMMUNITY;
selectorType = DSpaceObjectType.COMMUNITY;
action = SelectorActionType.EDIT;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route);
}
/**
* Navigate to the community edit page
*/
navigate(dso: DSpaceObject) {
this.router.navigate([getCommunityEditPath(dso.uuid)]);
}
}

View File

@@ -0,0 +1,66 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { EditItemSelectorComponent } from './edit-item-selector.component';
import { Item } from '../../../../core/shared/item.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { RouterStub } from '../../../testing/router-stub';
import * as itemRouter from '../../../../+item-page/item-page-routing.module';
import { MetadataValue } from '../../../../core/shared/metadata.models';
describe('EditItemSelectorComponent', () => {
let component: EditItemSelectorComponent;
let fixture: ComponentFixture<EditItemSelectorComponent>;
let debugElement: DebugElement;
const item = new Item();
item.uuid = '1234-1234-1234-1234';
item.metadata = { 'dc.title': [Object.assign(new MetadataValue(), { value: 'Item title', language: undefined })] };
const router = new RouterStub();
const itemRD = new RemoteData(false, false, true, undefined, item);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const editPath = 'testEditPath';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [EditItemSelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ item: itemRD }) } } } }
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
spyOnProperty(itemRouter, 'getItemEditPath').and.callFake(() => {
return () => editPath;
});
fixture = TestBed.createComponent(EditItemSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct edit path when navigate is called', () => {
component.navigate(item);
expect(router.navigate).toHaveBeenCalledWith([editPath]);
});
});

View File

@@ -0,0 +1,42 @@
import { Component, Input, OnInit } from '@angular/core';
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router';
import { Community } from '../../../../core/shared/community.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Collection } from '../../../../core/shared/collection.model';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Item } from '../../../../core/shared/item.model';
import { getItemEditPath } from '../../../../+item-page/item-page-routing.module';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from '../dso-selector-modal-wrapper.component';
/**
* Component to wrap a list of existing items inside a modal
* Used to choose an item from to edit
*/
@Component({
selector: 'ds-edit-item-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
})
export class EditItemSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.ITEM;
selectorType = DSpaceObjectType.ITEM;
action = SelectorActionType.EDIT;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route);
}
/**
* Navigate to the item edit page
*/
navigate(dso: DSpaceObject) {
this.router.navigate([getItemEditPath(dso.uuid)]);
}
}

View File

@@ -12,7 +12,7 @@ export enum MenuID {
* List of possible MenuItemTypes
*/
export enum MenuItemType {
TEXT, LINK, ALTMETRIC, SEARCH
TEXT, LINK, ALTMETRIC, SEARCH, ONCLICK
}
/**

View File

@@ -0,0 +1,11 @@
import { MenuItemModel } from './menu-item.model';
import { MenuItemType } from '../../initial-menus-state';
/**
* Model representing an OnClick Menu Section
*/
export class OnClickMenuItemModel implements MenuItemModel {
type = MenuItemType.ONCLICK;
text: string;
function: () => {};
}

View File

@@ -0,0 +1 @@
<a class="nav-item nav-link" role="button" (click)="item.function()">{{item.text | translate}}</a>

View File

@@ -0,0 +1,3 @@
a {
cursor: pointer;
}

View File

@@ -0,0 +1,52 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TextMenuItemComponent } from './text-menu-item.component';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { OnClickMenuItemComponent } from './onclick-menu-item.component';
import { OnClickMenuItemModel } from './models/onclick.model';
describe('OnClickMenuItemComponent', () => {
let component: OnClickMenuItemComponent;
let fixture: ComponentFixture<OnClickMenuItemComponent>;
let debugElement: DebugElement;
const text = 'HELLO';
const func = () => {
/* comment */
};
const item = Object.assign(new OnClickMenuItemModel(), { text, function: func });
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [OnClickMenuItemComponent],
providers: [
{ provide: 'itemModelProvider', useValue: item },
],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();
}));
beforeEach(() => {
spyOn(item, 'function');
fixture = TestBed.createComponent(OnClickMenuItemComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should contain the correct text', () => {
expect(component).toBeTruthy();
});
it('should contain the text element', () => {
const textContent = debugElement.query(By.css('a')).nativeElement.textContent;
expect(textContent).toEqual(text);
});
it('should contain call the function on the item when clicked', () => {
debugElement.query(By.css('a.nav-link')).triggerEventHandler('click', {});
expect(item.function).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,20 @@
import { Component, Inject } from '@angular/core';
import { MenuItemType } from '../initial-menus-state';
import { rendersMenuItemForType } from '../menu-item.decorator';
import { OnClickMenuItemModel } from './models/onclick.model';
/**
* Component that renders a menu section of type ONCLICK
*/
@Component({
selector: 'ds-onclick-menu-item',
styleUrls: ['./onclick-menu-item.component.scss'],
templateUrl: './onclick-menu-item.component.html'
})
@rendersMenuItemForType(MenuItemType.ONCLICK)
export class OnClickMenuItemComponent {
item: OnClickMenuItemModel;
constructor(@Inject('itemModelProvider') item: OnClickMenuItemModel) {
this.item = item;
}
}

View File

@@ -5,17 +5,20 @@ import { TranslateModule } from '@ngx-translate/core';
import { RouterModule } from '@angular/router';
import { LinkMenuItemComponent } from './menu-item/link-menu-item.component';
import { TextMenuItemComponent } from './menu-item/text-menu-item.component';
import { OnClickMenuItemComponent } from './menu-item/onclick-menu-item.component';
const COMPONENTS = [
MenuSectionComponent,
MenuComponent,
LinkMenuItemComponent,
TextMenuItemComponent
TextMenuItemComponent,
OnClickMenuItemComponent
];
const ENTRY_COMPONENTS = [
LinkMenuItemComponent,
TextMenuItemComponent
TextMenuItemComponent,
OnClickMenuItemComponent
];
const MODULES = [

View File

@@ -10,19 +10,30 @@ import { AppState } from '../../app.reducer';
import { AddUrlToHistoryAction } from '../history/history.actions';
import { historySelector } from '../history/selectors';
/**
* Service to keep track of the current query parameters
*/
@Injectable()
export class RouteService {
constructor(private route: ActivatedRoute, private router: Router, private store: Store<AppState>) {
}
/**
* Retrieves all query parameter values based on a parameter name
* @param paramName The name of the parameter to look for
*/
getQueryParameterValues(paramName: string): Observable<string[]> {
return this.getQueryParamMap().pipe(
map((params) => [...params.getAll(paramName)]),
distinctUntilChanged()
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
);
}
/**
* Retrieves a single query parameter values based on a parameter name
* @param paramName The name of the parameter to look for
*/
getQueryParameterValue(paramName: string): Observable<string> {
return this.getQueryParamMap().pipe(
map((params) => params.get(paramName)),
@@ -30,6 +41,10 @@ export class RouteService {
);
}
/**
* Checks if the query parameter currently exists in the route
* @param paramName The name of the parameter to look for
*/
hasQueryParam(paramName: string): Observable<boolean> {
return this.getQueryParamMap().pipe(
map((params) => params.has(paramName)),
@@ -37,6 +52,11 @@ export class RouteService {
);
}
/**
* Checks if the query parameter with a specific value currently exists in the route
* @param paramName The name of the parameter to look for
* @param paramValue The value of the parameter to look for
*/
hasQueryParamWithValue(paramName: string, paramValue: string): Observable<boolean> {
return this.getQueryParamMap().pipe(
map((params) => params.getAll(paramName).indexOf(paramValue) > -1),
@@ -44,6 +64,10 @@ export class RouteService {
);
}
/**
* Retrieves all query parameters of which the parameter name starts with the given prefix
* @param prefix The prefix of the parameter name to look for
*/
getQueryParamsWithPrefix(prefix: string): Observable<Params> {
return this.getQueryParamMap().pipe(
map((qparams) => {
@@ -55,7 +79,8 @@ export class RouteService {
});
return params;
}),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)));
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
);
}
public getQueryParamMap(): Observable<any> {

View File

@@ -116,6 +116,17 @@ import { AutoFocusDirective } from './utils/auto-focus.directive';
import { ComcolPageBrowseByComponent } from './comcol-page-browse-by/comcol-page-browse-by.component';
import { StartsWithDateComponent } from './starts-with/date/starts-with-date.component';
import { StartsWithTextComponent } from './starts-with/text/starts-with-text.component';
import { DSOSelectorComponent } from './dso-selector/dso-selector/dso-selector.component';
import { CreateCommunityParentSelectorComponent } from './dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component';
import { CreateItemParentSelectorComponent } from './dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component';
import { CreateCollectionParentSelectorComponent } from './dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component';
import { CommunitySearchResultListElementComponent } from './object-list/search-result-list-element/community-search-result/community-search-result-list-element.component';
import { CollectionSearchResultListElementComponent } from './object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component';
import { ItemSearchResultListElementComponent } from './object-list/search-result-list-element/item-search-result/item-search-result-list-element.component';
import { EditItemSelectorComponent } from './dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component';
import { EditCommunitySelectorComponent } from './dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component';
import { EditCollectionSelectorComponent } from './dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component';
import { DSOSelectorModalWrapperComponent } from './dso-selector/modal-wrappers/dso-selector-modal-wrapper.component';
import { ItemListPreviewComponent } from './object-list/item-list-preview/item-list-preview.component';
import { ItemPageAuthorFieldComponent } from '../+item-page/simple/field-components/specific-field/author/item-page-author-field.component';
import { ItemPageDateFieldComponent } from '../+item-page/simple/field-components/specific-field/date/item-page-date-field.component';
@@ -233,6 +244,16 @@ const COMPONENTS = [
TruncatablePartComponent,
BrowseByComponent,
InputSuggestionsComponent,
DSOSelectorComponent,
CreateCommunityParentSelectorComponent,
CreateCollectionParentSelectorComponent,
CreateItemParentSelectorComponent,
EditCommunitySelectorComponent,
EditCollectionSelectorComponent,
EditItemSelectorComponent,
CommunitySearchResultListElementComponent,
CollectionSearchResultListElementComponent,
ItemSearchResultListElementComponent,
];
const ENTRY_COMPONENTS = [
@@ -242,6 +263,9 @@ const ENTRY_COMPONENTS = [
CommunityListElementComponent,
MyDSpaceResultListElementComponent,
SearchResultListElementComponent,
CommunitySearchResultListElementComponent,
CollectionSearchResultListElementComponent,
ItemSearchResultListElementComponent,
ItemGridElementComponent,
CollectionGridElementComponent,
CommunityGridElementComponent,
@@ -260,7 +284,14 @@ const ENTRY_COMPONENTS = [
DsDynamicFormArrayComponent,
DsDatePickerInlineComponent,
StartsWithDateComponent,
StartsWithTextComponent
StartsWithTextComponent,
DSOSelectorComponent,
CreateCommunityParentSelectorComponent,
CreateCollectionParentSelectorComponent,
CreateItemParentSelectorComponent,
EditCommunitySelectorComponent,
EditCollectionSelectorComponent,
EditItemSelectorComponent,
];
const SHARED_ITEM_PAGE_COMPONENTS = [

View File

@@ -40,4 +40,8 @@ export class SearchServiceStub {
getFilterLabels() {
return observableOf([]);
}
search() {
return observableOf({});
}
}

View File

@@ -282,6 +282,10 @@
"@types/connect" "*"
"@types/node" "*"
"@types/circular-json@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@types/circular-json/-/circular-json-0.4.0.tgz#7401f7e218cfe87ad4c43690da5658b9acaf51be"
"@types/connect@*":
version "3.4.32"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.32.tgz#aa0e9616b9435ccad02bc52b5b454ffc2c70ba28"
@@ -441,6 +445,10 @@
dependencies:
"@types/node" "*"
"@types/stacktrace-js@^0.0.32":
version "0.0.32"
resolved "https://registry.yarnpkg.com/@types/stacktrace-js/-/stacktrace-js-0.0.32.tgz#d23e4a36a5073d39487fbea8234cc6186862d389"
"@types/strip-bom@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2"
@@ -1870,6 +1878,10 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
inherits "^2.0.1"
safe-buffer "^5.0.1"
circular-json@^0.5.0:
version "0.5.9"
resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.5.9.tgz#932763ae88f4f7dead7a0d09c8a51a4743a53b1d"
circular-json@^0.5.5:
version "0.5.5"
resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.5.5.tgz#64182ef359042d37cd8e767fc9de878b1e9447d3"
@@ -3037,6 +3049,12 @@ error-ex@^1.2.0, error-ex@^1.3.1:
dependencies:
is-arrayish "^0.2.1"
error-stack-parser@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.0.2.tgz#4ae8dbaa2bf90a8b450707b9149dcabca135520d"
dependencies:
stackframe "^1.0.4"
es-abstract@^1.4.3, es-abstract@^1.5.1:
version "1.12.0"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.12.0.tgz#9dbbdd27c6856f0001421ca18782d786bf8a6165"
@@ -8397,6 +8415,16 @@ rx@^4.1.0:
resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782"
integrity sha1-pfE/957zt0D+MKqAP7CfmIBdR4I=
rxjs-spy@^7.5.1:
version "7.5.1"
resolved "https://registry.yarnpkg.com/rxjs-spy/-/rxjs-spy-7.5.1.tgz#1a9ef50bc8d7dd00d9ecf3c54c00929231eaf319"
dependencies:
"@types/circular-json" "^0.4.0"
"@types/stacktrace-js" "^0.0.32"
circular-json "^0.5.0"
error-stack-parser "^2.0.1"
stacktrace-gps "^3.0.2"
rxjs@6.2.2, rxjs@^6.0.0, rxjs@^6.1.0:
version "6.2.2"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.2.2.tgz#eb75fa3c186ff5289907d06483a77884586e1cf9"
@@ -8905,6 +8933,10 @@ source-map@0.5.0:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.0.tgz#0fe96503ac86a5adb5de63f4e412ae4872cdbe86"
integrity sha1-D+llA6yGpa213mP05BKuSHLNvoY=
source-map@0.5.6:
version "0.5.6"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412"
source-map@0.7.3:
version "0.7.3"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
@@ -9044,6 +9076,17 @@ ssri@^5.2.4:
dependencies:
safe-buffer "^5.1.1"
stackframe@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.0.4.tgz#357b24a992f9427cba6b545d96a14ed2cbca187b"
stacktrace-gps@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/stacktrace-gps/-/stacktrace-gps-3.0.2.tgz#33f8baa4467323ab2bd1816efa279942ba431ccc"
dependencies:
source-map "0.5.6"
stackframe "^1.0.4"
static-extend@^0.1.1:
version "0.1.2"
resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"