mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 18:14:17 +00:00
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:
@@ -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",
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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',
|
||||
|
@@ -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 {
|
||||
|
||||
|
@@ -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],
|
||||
|
@@ -15,7 +15,6 @@ import { DeleteCollectionPageComponent } from './delete-collection-page/delete-c
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
SearchPageModule,
|
||||
CollectionPageRoutingModule
|
||||
],
|
||||
declarations: [
|
||||
|
@@ -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],
|
||||
|
@@ -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">
|
||||
|
@@ -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>
|
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -1 +1 @@
|
||||
<ng-container *ngComponentOutlet="getSearchFilter(); injector: objectInjector;"></ng-container>
|
||||
<ng-container *ngComponentOutlet="searchFilter injector: objectInjector;"></ng-container>
|
@@ -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);
|
||||
}
|
||||
|
@@ -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');
|
||||
|
@@ -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', [
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set the initial state of a filter to expanded
|
||||
*/
|
||||
export class SearchFilterInitialExpandAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.INITIAL_EXPAND;
|
||||
export class SearchFilterInitializeAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.INITIALIZE;
|
||||
initiallyExpanded;
|
||||
constructor(filter: SearchFilterConfig) {
|
||||
super(filter.name);
|
||||
this.initiallyExpanded = filter.isOpenByDefault;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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>
|
||||
|
@@ -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', () => {
|
||||
|
@@ -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));
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
});
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -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,13 +88,14 @@ export class SearchFilterService {
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
}),
|
||||
distinctUntilChanged()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the current page of a given filter
|
||||
* @param {string} filterName The filtername for which the page state is checked
|
||||
* @param {string} filterName The filter name for which the page state is checked
|
||||
* @returns {Observable<boolean>} Emits the current page state of the given filter, if it's unavailable, return 1
|
||||
*/
|
||||
getPage(filterName: string): Observable<number> {
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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">
|
||||
|
@@ -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>
|
||||
|
@@ -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' };
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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
|
||||
}),)
|
||||
trackUpdate(index, config: SearchFilterConfig) {
|
||||
return config ? config.name : undefined;
|
||||
}
|
||||
))
|
||||
}
|
||||
}),
|
||||
first(),
|
||||
startWith(true),);
|
||||
}
|
||||
|
||||
private setLoading() {
|
||||
this.isLoadingFilters$.next(true);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (hasValue(this.sub)) {
|
||||
this.sub.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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
|
||||
})
|
||||
|
@@ -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();
|
||||
|
@@ -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),
|
||||
|
@@ -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));
|
||||
})
|
||||
);
|
||||
|
||||
|
@@ -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' },
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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);
|
||||
|
106
src/app/core/cache/object-cache.service.ts
vendored
106
src/app/core/cache/object-cache.service.ts
vendored
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
|
@@ -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) => {
|
||||
|
@@ -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,
|
||||
|
@@ -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');
|
||||
|
7
src/app/core/core.selectors.ts
Normal file
7
src/app/core/core.selectors.ts
Normal 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');
|
@@ -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', () => {
|
||||
|
@@ -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))
|
||||
);
|
||||
|
@@ -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');
|
||||
});
|
||||
|
||||
|
@@ -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)) {
|
||||
|
@@ -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,
|
||||
|
@@ -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> {
|
||||
|
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -5,22 +5,79 @@ 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';
|
||||
|
||||
/**
|
||||
* The base selector function to select the request state in the store
|
||||
*/
|
||||
const requestCacheSelector = createSelector(
|
||||
coreSelector,
|
||||
(state: CoreState) => state['data/request']
|
||||
);
|
||||
|
||||
/**
|
||||
* 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
|
||||
* contains a given substring
|
||||
* @param selector MemoizedSelector to start from
|
||||
* @param name The name of the index substate we're fetching request UUIDs from
|
||||
* @param href Substring that the request's href should contain
|
||||
*/
|
||||
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 href Substring that the request's href should contain
|
||||
*/
|
||||
const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => {
|
||||
let result = [];
|
||||
if (isNotEmpty(state)) {
|
||||
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[] = [];
|
||||
@@ -28,57 +85,16 @@ export class RequestService {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a selector that fetches a list of request UUIDs from a given index substate of which the request href
|
||||
* contains a given substring
|
||||
* @param selector MemoizedSelector to start from
|
||||
* @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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] {
|
||||
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]];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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]: {
|
||||
|
@@ -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) {
|
||||
|
94
src/app/core/index/index.selectors.ts
Normal file
94
src/app/core/index/index.selectors.ts
Normal 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
|
||||
);
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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);
|
||||
});
|
||||
|
||||
})
|
||||
;
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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 } });
|
||||
});
|
||||
|
||||
});
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -0,0 +1,3 @@
|
||||
#create-community-or-separator {
|
||||
top: 0;
|
||||
}
|
@@ -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 } });
|
||||
});
|
||||
|
||||
});
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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]);
|
||||
});
|
||||
|
||||
});
|
@@ -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...
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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 */
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -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]);
|
||||
});
|
||||
|
||||
});
|
@@ -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)]);
|
||||
}
|
||||
}
|
@@ -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]);
|
||||
});
|
||||
|
||||
});
|
@@ -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)]);
|
||||
}
|
||||
}
|
@@ -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]);
|
||||
});
|
||||
|
||||
});
|
@@ -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)]);
|
||||
}
|
||||
}
|
@@ -12,7 +12,7 @@ export enum MenuID {
|
||||
* List of possible MenuItemTypes
|
||||
*/
|
||||
export enum MenuItemType {
|
||||
TEXT, LINK, ALTMETRIC, SEARCH
|
||||
TEXT, LINK, ALTMETRIC, SEARCH, ONCLICK
|
||||
}
|
||||
|
||||
/**
|
||||
|
11
src/app/shared/menu/menu-item/models/onclick.model.ts
Normal file
11
src/app/shared/menu/menu-item/models/onclick.model.ts
Normal 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: () => {};
|
||||
}
|
@@ -0,0 +1 @@
|
||||
<a class="nav-item nav-link" role="button" (click)="item.function()">{{item.text | translate}}</a>
|
@@ -0,0 +1,3 @@
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
20
src/app/shared/menu/menu-item/onclick-menu-item.component.ts
Normal file
20
src/app/shared/menu/menu-item/onclick-menu-item.component.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -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 = [
|
||||
|
@@ -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> {
|
||||
@@ -74,7 +99,7 @@ export class RouteService {
|
||||
public saveRouting(): void {
|
||||
this.router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
.subscribe(({urlAfterRedirects}: NavigationEnd) => {
|
||||
.subscribe(({ urlAfterRedirects }: NavigationEnd) => {
|
||||
this.store.dispatch(new AddUrlToHistoryAction(urlAfterRedirects))
|
||||
});
|
||||
}
|
||||
|
@@ -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 = [
|
||||
|
@@ -40,4 +40,8 @@ export class SearchServiceStub {
|
||||
getFilterLabels() {
|
||||
return observableOf([]);
|
||||
}
|
||||
|
||||
search() {
|
||||
return observableOf({});
|
||||
}
|
||||
}
|
||||
|
43
yarn.lock
43
yarn.lock
@@ -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"
|
||||
|
Reference in New Issue
Block a user