diff --git a/package.json b/package.json
index 4a1fe8884e..b0d3146e24 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/resources/i18n/en.json b/resources/i18n/en.json
index e9534d3df9..9183a3ec4d 100644
--- a/resources/i18n/en.json
+++ b/resources/i18n/en.json
@@ -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"
}
}
diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts
index c99e8adc58..590caaaccf 100644
--- a/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts
+++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts
@@ -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);
diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts
index eb48f64d4d..f148627297 100644
--- a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts
+++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts
@@ -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',
diff --git a/src/app/+admin/admin.module.ts b/src/app/+admin/admin.module.ts
index 41d00223ab..1495d0fd8c 100644
--- a/src/app/+admin/admin.module.ts
+++ b/src/app/+admin/admin.module.ts
@@ -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 {
diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts
index ddcf36a0cc..cdbd7650b2 100644
--- a/src/app/+collection-page/collection-page-routing.module.ts
+++ b/src/app/+collection-page/collection-page-routing.module.ts
@@ -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],
diff --git a/src/app/+collection-page/collection-page.module.ts b/src/app/+collection-page/collection-page.module.ts
index 8424cc02a4..f0e4138d2d 100644
--- a/src/app/+collection-page/collection-page.module.ts
+++ b/src/app/+collection-page/collection-page.module.ts
@@ -15,7 +15,6 @@ import { DeleteCollectionPageComponent } from './delete-collection-page/delete-c
imports: [
CommonModule,
SharedModule,
- SearchPageModule,
CollectionPageRoutingModule
],
declarations: [
diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts
index 02f28f6375..cecd17ec10 100644
--- a/src/app/+community-page/community-page-routing.module.ts
+++ b/src/app/+community-page/community-page-routing.module.ts
@@ -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],
diff --git a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html
index 32d9ea6e77..968bf9e420 100644
--- a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html
+++ b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html
@@ -1,24 +1,9 @@
-
-
- {{value}}
-
+
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html
new file mode 100644
index 0000000000..7ab7ffd0ca
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html
@@ -0,0 +1,9 @@
+
+
+ {{filterValue.value}}
+
+ {{filterValue.count}}
+
+
\ No newline at end of file
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts
new file mode 100644
index 0000000000..f1dbedfb40
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts
@@ -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
;
+ 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();
+ });
+ });
+});
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts
new file mode 100644
index 0000000000..7a6a51e99d
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts
@@ -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;
+
+ /**
+ * Emits true when this option should be visible and false when it should be invisible
+ */
+ isVisible: Observable;
+
+ /**
+ * 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 {
+ 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();
+ }
+ }
+}
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html
new file mode 100644
index 0000000000..b485fe0fd0
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html
@@ -0,0 +1,8 @@
+
+ {{filterValue.value}}
+
+ {{filterValue.count}}
+
+
\ No newline at end of file
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts
new file mode 100644
index 0000000000..218730263b
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts
@@ -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;
+ 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();
+ });
+ });
+});
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts
new file mode 100644
index 0000000000..b7f02ad18b
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts
@@ -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;
+
+ /**
+ * 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 {
+ 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();
+ }
+ }
+}
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html
new file mode 100644
index 0000000000..ba43bae100
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html
@@ -0,0 +1,6 @@
+
+
+ {{selectedValue}}
+
\ No newline at end of file
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts
new file mode 100644
index 0000000000..545ba1d66b
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts
@@ -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;
+ 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
+ });
+ });
+ });
+});
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts
new file mode 100644
index 0000000000..5137bf8ffc
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts
@@ -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;
+
+ /**
+ * 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();
+ }
+ }
+}
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html
index b7e03af473..4a325d9b3c 100644
--- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html
@@ -1 +1 @@
-
+
\ No newline at end of file
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts
index bc088777fa..6369a7691e 100644
--- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts
@@ -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;
/**
* 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);
}
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts
index 498c41dd6c..cb3d4730b4 100644
--- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts
@@ -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');
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts
index 659f49413c..8bdf36bf9d 100644
--- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts
@@ -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;
+ selectedValues$: Observable;
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;
+
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} The changed filter parameters
- */
- getRemoveParams(value: string): Observable {
- 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} The changed filter parameters
- */
- getAddParams(value: string): Observable {
- 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', [
diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts b/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts
index 2e556b32d6..f7f80eefff 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts
@@ -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;
+ }
}
/**
diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-filter.component.html
index 13fc6d9c7c..b248880581 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.component.html
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.html
@@ -1,7 +1,7 @@
-
+
{{'search.filters.filter.' + filter.name + '.head'| translate}}
-
+ [ngClass]="(collapsed$ | async) ? 'fa-plus' : 'fa-minus'">
+
diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts
index caa5a6febc..30ef349675 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts
@@ -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
;
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;
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;
beforeEach(() => {
filterService.isCollapsed = () => observableOf(false);
- isActive = comp.isCollapsed();
+ isActive = (comp as any).isCollapsed();
});
it('should return an observable containing false', () => {
diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts
index 385f83eae2..cce017f94e 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts
@@ -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;
+
+ /**
+ * Emits all currently selected values for this filter
+ */
+ selectedValues$: Observable;
+
+ /**
+ * Emits true when the current filter is supposed to be shown
+ */
+ active$: Observable;
+
+ 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} Emits true when the current state of the filter is collapsed, false when it's expanded
*/
- isCollapsed(): Observable {
+ private isCollapsed(): Observable {
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} Emits a list of all values that are currently active for this filter
*/
- getSelectedValues(): Observable {
+ private getSelectedValues(): Observable {
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} Emits true whenever a given filter config should be shown
+ */
+ private isActive(): Observable {
+ 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));
+ }
}
diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts
index 8fbfbf2e65..2f3268fba5 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts
@@ -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);
});
diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts
index f7e064fcc7..187bcd50d0 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts
@@ -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])) {
- return Object.assign({}, state, {
- [action.filterName]: {
- filterCollapsed: true,
- 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
- }
- });
- }
+ case SearchFilterActionTypes.INITIALIZE: {
+ const initAction = (action as SearchFilterInitializeAction);
+ return Object.assign({}, state, {
+ [action.filterName]: {
+ filterCollapsed: !initAction.initiallyExpanded,
+ page: 1
+ }
+ });
return state;
}
diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts
index 156e8d47ea..19239d899c 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts
@@ -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));
});
});
diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts
index bf21eab367..bed4b1777f 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts
@@ -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 = new InjectionToken('filterConfig');
@@ -60,7 +60,7 @@ export class SearchFilterService {
getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable {
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} Emits the current page state of the given filter, if it's unavailable, return 1
*/
getPage(filterName: string): Observable {
@@ -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));
}
/**
diff --git a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html
index 812f543716..b6ae0ada63 100644
--- a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html
+++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html
@@ -1,24 +1,9 @@
-
-
- {{value}}
-
+
diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html
index 352c1710c0..9d35cc518a 100644
--- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html
+++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html
@@ -24,16 +24,7 @@
diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts
index 6f3450e18e..930ea8c9fb 100644
--- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts
@@ -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' };
diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts
index af0676ffe2..60eaaa058e 100644
--- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts
@@ -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
} 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);
- }
}
diff --git a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html
index fcc2393b93..25ff8e46d3 100644
--- a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html
+++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html
@@ -1,26 +1,9 @@
-
-
- {{value}}
-
-
+
+
@@ -40,6 +23,5 @@
(submitSuggestion)="onSubmit($event)"
(clickSuggestion)="onClick($event)"
(findSuggestions)="findSuggestions($event)"
- ngDefaultControl
- >
+ ngDefaultControl>
diff --git a/src/app/+search-page/search-filters/search-filters.component.html b/src/app/+search-page/search-filters/search-filters.component.html
index 310d6502c7..57ca093259 100644
--- a/src/app/+search-page/search-filters/search-filters.component.html
+++ b/src/app/+search-page/search-filters/search-filters.component.html
@@ -1,7 +1,7 @@
{{"search.filters.head" | translate}}
-
-
+
-
{{"search.filters.reset" | translate}}
+
{{"search.filters.reset" | translate}}
diff --git a/src/app/+search-page/search-filters/search-filters.component.ts b/src/app/+search-page/search-filters/search-filters.component.ts
index d6116843be..876d2a5f74 100644
--- a/src/app/+search-page/search-filters/search-filters.component.ts
+++ b/src/app/+search-page/search-filters/search-filters.component.ts
@@ -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
>;
/**
* 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 = new BehaviorSubject(true);
-
- /**
- * The current paginated search options
- */
- searchOptions$: Observable;
-
- 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) => {
- 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} Emits true whenever a given filter config should be shown
+ * Prevent unnecessary rerendering
*/
- isActive(filterConfig: SearchFilterConfig): Observable {
- console.log('isActive', filterConfig);
- return this.filterService.getSelectedValuesForFilter(filterConfig).pipe(
- mergeMap((isActive) => {
- if (isNotEmpty(isActive)) {
- return observableOf(true);
- } else {
- return this.searchOptions$.pipe(
- switchMap((options) => {
- return this.searchService.getFacetValuesFor(filterConfig, 1, options).pipe(
- filter((RD) => !RD.isLoading),
- map((valuesRD) => {
- return valuesRD.payload.totalElements > 0
- }),)
- }
- ))
- }
- }),
- first(),
- startWith(true),);
+ trackUpdate(index, config: SearchFilterConfig) {
+ return config ? config.name : undefined;
}
-
- private setLoading() {
- this.isLoadingFilters$.next(true);
- this.cdr.detectChanges();
- }
-
- ngOnDestroy(): void {
- if (hasValue(this.sub)) {
- this.sub.unsubscribe();
- }
- }
-
}
diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts
index ff23f92b2c..ec44a5d74e 100644
--- a/src/app/+search-page/search-page.module.ts
+++ b/src/app/+search-page/search-page.module.ts
@@ -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
})
diff --git a/src/app/+search-page/search-service/search-configuration.service.spec.ts b/src/app/+search-page/search-service/search-configuration.service.spec.ts
index af8897c93b..f1f4ef8bdc 100644
--- a/src/app/+search-page/search-service/search-configuration.service.spec.ts
+++ b/src/app/+search-page/search-service/search-configuration.service.spec.ts
@@ -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();
diff --git a/src/app/+search-page/search-service/search-configuration.service.ts b/src/app/+search-page/search-service/search-configuration.service.ts
index 31ba839eb5..380bace080 100644
--- a/src/app/+search-page/search-service/search-configuration.service.ts
+++ b/src/app/+search-page/search-service/search-configuration.service.ts
@@ -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),
diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts
index e98fecd830..361f08b724 100644
--- a/src/app/+search-page/search-service/search.service.ts
+++ b/src/app/+search-page/search-service/search.service.ts
@@ -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 = 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> = 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));
})
);
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index b0c9305a66..cb80d0165e 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -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' },
diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts
index e30b8c9955..c0b359e7ea 100644
--- a/src/app/core/cache/builders/remote-data-build.service.ts
+++ b/src/app/core/cache/builders/remote-data-build.service.ts
@@ -46,13 +46,13 @@ export class RemoteDataBuildService {
const payload$ =
observableCombineLatest(
href$.pipe(
- switchMap((href: string) => this.objectCache.getBySelfLink(href)),
+ switchMap((href: string) => this.objectCache.getObjectBySelfLink(href)),
startWith(undefined)),
requestEntry$.pipe(
getResourceLinksFromResponse(),
switchMap((resourceSelfLinks: string[]) => {
if (isNotEmpty(resourceSelfLinks)) {
- return this.objectCache.getBySelfLink(resourceSelfLinks[0]);
+ return this.objectCache.getObjectBySelfLink(resourceSelfLinks[0]);
} else {
return observableOf(undefined);
}
diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts
index af353a38c1..eae7c06be7 100644
--- a/src/app/core/cache/object-cache.service.spec.ts
+++ b/src/app/core/cache/object-cache.service.spec.ts
@@ -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);
diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts
index d4d52b404f..483de65b98 100644
--- a/src/app/core/cache/object-cache.service.ts
+++ b/src/app/core/cache/object-cache.service.ts
@@ -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 {
- return pathSelector(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 {
- return pathSelector(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 => 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
- * An observable of the requested object
+ * @return Observable>
+ * An observable of the requested object in normalized form
*/
- getByUUID(uuid: string): Observable> {
+ getObjectByUUID(uuid: string): Observable> {
return this.store.pipe(
select(selfLinkFromUuidSelector(uuid)),
- mergeMap((selfLink: string) => this.getBySelfLink(selfLink)
+ mergeMap((selfLink: string) => this.getObjectBySelfLink(selfLink)
)
)
}
- getBySelfLink(selfLink: string): Observable> {
- 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>
+ * An observable of the requested object in normalized form
+ */
+ getObjectBySelfLink(selfLink: string): Observable> {
+ 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 {
+ /**
+ * Get an observable of the object cache entry with the specified selfLink
+ *
+ * @param selfLink
+ * The selfLink of the object to get
+ * @return Observable
+ * An observable of the requested object cache entry
+ */
+ getBySelfLink(selfLink: string): Observable {
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
+ * An observable of the request's uuid
+ */
getRequestUUIDBySelfLink(selfLink: string): Observable {
- 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
+ * An observable of the request's uuid
+ */
getRequestUUIDByObjectUUID(uuid: string): Observable {
return this.store.pipe(
select(selfLinkFromUuidSelector(uuid)),
@@ -147,7 +181,7 @@ export class ObjectCacheService {
*/
getList(selfLinks: string[]): Observable>> {
return observableCombineLatest(
- selfLinks.map((selfLink: string) => this.getBySelfLink(selfLink))
+ selfLinks.map((selfLink: string) => this.getObjectBySelfLink(selfLink))
);
}
diff --git a/src/app/core/cache/server-sync-buffer.effects.spec.ts b/src/app/core/cache/server-sync-buffer.effects.spec.ts
index 724a4b1c6b..773e0ab60c 100644
--- a/src/app/core/cache/server-sync-buffer.effects.spec.ts
+++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts
@@ -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);
diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts
index 0d7392e555..3aa6ad312f 100644
--- a/src/app/core/cache/server-sync-buffer.effects.ts
+++ b/src/app/core/cache/server-sync-buffer.effects.ts
@@ -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} ApplyPatchObjectCacheAction to be dispatched
*/
private applyPatch(href: string): Observable {
- const patchObject = this.objectCache.getBySelfLink(href).pipe(take(1));
+ const patchObject = this.objectCache.getObjectBySelfLink(href).pipe(take(1));
return patchObject.pipe(
map((object) => {
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
index 3d399d6284..648d02d4ca 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -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,
diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts
index ebfe578a6d..c93b4bf44b 100644
--- a/src/app/core/core.reducers.ts
+++ b/src/app/core/core.reducers.ts
@@ -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 = {
'auth': authReducer,
'json/patch': jsonPatchOperationsReducer
};
-
-export const coreSelector = createFeatureSelector('core');
diff --git a/src/app/core/core.selectors.ts b/src/app/core/core.selectors.ts
new file mode 100644
index 0000000000..60365be7c2
--- /dev/null
+++ b/src/app/core/core.selectors.ts
@@ -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('core');
diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts
index cf7b6185ea..7f628fc5b9 100644
--- a/src/app/core/data/comcol-data.service.spec.ts
+++ b/src/app/core/data/comcol-data.service.spec.ts
@@ -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', () => {
diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts
index 693b8af58b..9d82cc5047 100644
--- a/src/app/core/data/comcol-data.service.ts
+++ b/src/app/core/data/comcol-data.service.ts
@@ -49,7 +49,7 @@ export abstract class ComColDataService 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))
);
diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts
index 910506bc29..4a244db24f 100644
--- a/src/app/core/data/data.service.spec.ts
+++ b/src/app/core/data/data.service.spec.ts
@@ -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');
});
diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts
index 984495078b..815c531218 100644
--- a/src/app/core/data/data.service.ts
+++ b/src/app/core/data/data.service.ts
@@ -175,7 +175,7 @@ export abstract class DataService {
* @param {DSpaceObject} object The given object
*/
update(object: T): Observable> {
- 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)) {
diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts
index 85e17b5b2f..a13fb9487b 100644
--- a/src/app/core/data/object-updates/object-updates.service.ts
+++ b/src/app/core/data/object-updates/object-updates.service.ts
@@ -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,
diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts
index 40680c9a37..1dedcc89e2 100644
--- a/src/app/core/data/request.models.ts
+++ b/src/app/core/data/request.models.ts
@@ -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 {
diff --git a/src/app/core/data/request.service.spec.ts b/src/app/core/data/request.service.spec.ts
index 28b340056b..ae6e18fbf7 100644
--- a/src/app/core/data/request.service.spec.ts
+++ b/src/app/core/data/request.service.spec.ts
@@ -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,63 +308,16 @@ describe('RequestService', () => {
expect(result).toEqual(expected);
});
});
- describe('in the responseCache', () => {
+ describe('in the request cache', () => {
beforeEach(() => {
- spyOn(serviceAsAny, 'isReusable').and.returnValue(observableOf(true));
- spyOn(serviceAsAny, 'getByHref').and.returnValue(observableOf(undefined));
+ (objectCache.hasBySelfLink as any).and.returnValue(false);
+ spyOn(serviceAsAny, 'hasByHref').and.returnValue(true);
});
+ it('should return true', () => {
+ const result = serviceAsAny.isCachedOrPending(testGetRequest);
+ const expected = true;
- 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', () => {
- 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
- }
- }
- ));
- });
-
- it('should return true', () => {
- const result = serviceAsAny.isCachedOrPending(testGetRequest);
- const expected = true;
-
- expect(result).toEqual(expected);
- });
+ expect(result).toEqual(expected);
});
});
});
@@ -462,104 +415,128 @@ 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;
+ const requestEntry = {
+ completed: true,
+ response: {
+ isSuccessful: true,
+ timeAdded: timeAdded
+ },
+ request: {
+ responseMsToLive: msToLive,
+ }
+ };
beforeEach(() => {
spyOn(Date.prototype, 'getTime').and.returnValue(now);
- spyOn(service, 'getByUUID').and.returnValue(observableOf({
- response: {
- isSuccessful: true,
- timeAdded: timeAdded
- },
- request: {
- responseMsToLive: msToLive
- }
- }));
- const uuid = 'f9b85788-881c-4994-86b6-bae8dad024d2';
- 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, its response was successful, and the response is not outdated', () => {
- let reusable;
+ let valid;
const now = 100000;
const timeAdded = 99999;
const msToLive = 100;
+ const requestEntry = {
+ completed: true,
+ response: {
+ isSuccessful: true,
+ timeAdded: timeAdded
+ },
+ request: {
+ responseMsToLive: msToLive
+ }
+ };
beforeEach(() => {
spyOn(Date.prototype, 'getTime').and.returnValue(now);
- spyOn(service, 'getByUUID').and.returnValue(observableOf({
- response: {
- isSuccessful: true,
- timeAdded: timeAdded
- },
- request: {
- responseMsToLive: msToLive
- }
- }));
- const uuid = 'f9b85788-881c-4994-86b6-bae8dad024d2';
- reusable = serviceAsAny.isReusable(uuid);
+ 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);
+ });
+ });
+ });
});
diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts
index fa6adfcc36..efc3ecb449 100644
--- a/src/app/core/data/request.service.ts
+++ b/src/app/core/data/request.service.ts
@@ -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 => 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, href: string): MemoizedSelector => 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,
- private indexStore: Store) {
- }
-
- private entryFromUUIDSelector(uuid: string): MemoizedSelector {
- return pathSelector(coreSelector, 'data/request', uuid);
- }
-
- private uuidFromHrefSelector(href: string): MemoizedSelector {
- return pathSelector(coreSelector, 'index', IndexName.REQUEST, href);
- }
-
- private originalUUIDFromUUIDSelector(uuid: string): MemoizedSelector {
- return pathSelector(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, name: string, href: string): MemoizedSelector {
- 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) {
}
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 {
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 {
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(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 = 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 {
- if (hasNoValue(uuid)) {
- return observableOf(false);
+ 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 {
- 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) {
- const timeOutdated = entry.response.timeAdded + entry.request.responseMsToLive;
- const isOutDated = new Date().getTime() > timeOutdated;
- return !isOutDated;
- } else {
- return false;
- }
- })
- );
+ 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;
+ }
+
}
diff --git a/src/app/core/index/index.reducer.spec.ts b/src/app/core/index/index.reducer.spec.ts
index d1403ac5bf..ef46c760c6 100644
--- a/src/app/core/index/index.reducer.spec.ts
+++ b/src/app/core/index/index.reducer.spec.ts
@@ -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]: {
diff --git a/src/app/core/index/index.reducer.ts b/src/app/core/index/index.reducer.ts
index 3597c786d8..b4cd8aa84b 100644
--- a/src/app/core/index/index.reducer.ts
+++ b/src/app/core/index/index.reducer.ts
@@ -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]: {
- [key: string]: string
- }
+/**
+ * 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) {
diff --git a/src/app/core/index/index.selectors.ts b/src/app/core/index/index.selectors.ts
new file mode 100644
index 0000000000..3c7b331a92
--- /dev/null
+++ b/src/app/core/index/index.selectors.ts
@@ -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 = 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 = 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 = 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 = 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 => 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 => 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 => createSelector(
+ requestUUIDIndexSelector,
+ (state: IndexState) => hasValue(state) ? state[uuid] : undefined
+ );
diff --git a/src/app/core/shared/selectors.ts b/src/app/core/shared/selectors.ts
deleted file mode 100644
index 7bd35d39c1..0000000000
--- a/src/app/core/shared/selectors.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { createSelector, MemoizedSelector } from '@ngrx/store';
-import { hasNoValue, isEmpty } from '../../shared/empty.util';
-
-export function pathSelector(selector: MemoizedSelector, ...path: string[]): MemoizedSelector {
- 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);
- }
-}
diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html
new file mode 100644
index 0000000000..1e0deed4b9
--- /dev/null
+++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html
@@ -0,0 +1,20 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts
new file mode 100644
index 0000000000..04111a4ea6
--- /dev/null
+++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts
@@ -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;
+ 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);
+ });
+
+})
+;
diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts
new file mode 100644
index 0000000000..04501e4923
--- /dev/null
+++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts
@@ -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 = 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>>>;
+
+ /**
+ * List of element references to all elements
+ */
+ @ViewChildren('listEntryElement') listElements: QueryList;
+
+ /**
+ * 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();
+ }
+ }
+}
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts
new file mode 100644
index 0000000000..9efeddeeab
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts
@@ -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;
+ 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 } });
+ });
+
+});
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts
new file mode 100644
index 0000000000..1e129c0dbe
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts
@@ -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);
+ }
+}
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html
new file mode 100644
index 0000000000..e6a0db3b62
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+ {{'dso-selector.create.community.sub-level' | translate}}
+
+
+
\ No newline at end of file
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss
new file mode 100644
index 0000000000..0daf4cfa5f
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss
@@ -0,0 +1,3 @@
+#create-community-or-separator {
+ top: 0;
+}
\ No newline at end of file
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts
new file mode 100644
index 0000000000..e1bb9c7997
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts
@@ -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;
+ 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 } });
+ });
+
+});
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts
new file mode 100644
index 0000000000..914dc7582f
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts
@@ -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);
+ }
+}
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts
new file mode 100644
index 0000000000..19bb58eb5a
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts
@@ -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;
+ 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]);
+ });
+
+});
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts
new file mode 100644
index 0000000000..dac5888bf7
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts
@@ -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...
+ }
+}
diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html
new file mode 100644
index 0000000000..88f4a6f917
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts
new file mode 100644
index 0000000000..ea857f7d62
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts
@@ -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;
+ 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 */
+ }
+}
diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts
new file mode 100644
index 0000000000..351a92302c
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts
@@ -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>;
+
+ /**
+ * 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();
+ }
+}
diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts
new file mode 100644
index 0000000000..5e60348527
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts
@@ -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;
+ 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]);
+ });
+
+});
diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts
new file mode 100644
index 0000000000..79660b9589
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts
@@ -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)]);
+ }
+}
diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts
new file mode 100644
index 0000000000..ac558a074a
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts
@@ -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;
+ 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]);
+ });
+
+});
diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts
new file mode 100644
index 0000000000..6b9efc1ff4
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts
@@ -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)]);
+ }
+}
diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts
new file mode 100644
index 0000000000..8ac04bb335
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts
@@ -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;
+ 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]);
+ });
+
+});
diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts
new file mode 100644
index 0000000000..9182df8045
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts
@@ -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)]);
+ }
+}
diff --git a/src/app/shared/menu/initial-menus-state.ts b/src/app/shared/menu/initial-menus-state.ts
index 7f3482f41f..7b900540b6 100644
--- a/src/app/shared/menu/initial-menus-state.ts
+++ b/src/app/shared/menu/initial-menus-state.ts
@@ -12,7 +12,7 @@ export enum MenuID {
* List of possible MenuItemTypes
*/
export enum MenuItemType {
- TEXT, LINK, ALTMETRIC, SEARCH
+ TEXT, LINK, ALTMETRIC, SEARCH, ONCLICK
}
/**
diff --git a/src/app/shared/menu/menu-item/models/onclick.model.ts b/src/app/shared/menu/menu-item/models/onclick.model.ts
new file mode 100644
index 0000000000..4cef3084f9
--- /dev/null
+++ b/src/app/shared/menu/menu-item/models/onclick.model.ts
@@ -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: () => {};
+}
diff --git a/src/app/shared/menu/menu-item/onclick-menu-item.component.html b/src/app/shared/menu/menu-item/onclick-menu-item.component.html
new file mode 100644
index 0000000000..96c9049ab3
--- /dev/null
+++ b/src/app/shared/menu/menu-item/onclick-menu-item.component.html
@@ -0,0 +1 @@
+{{item.text | translate}}
\ No newline at end of file
diff --git a/src/app/shared/menu/menu-item/onclick-menu-item.component.scss b/src/app/shared/menu/menu-item/onclick-menu-item.component.scss
new file mode 100644
index 0000000000..adb670aa6f
--- /dev/null
+++ b/src/app/shared/menu/menu-item/onclick-menu-item.component.scss
@@ -0,0 +1,3 @@
+a {
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/src/app/shared/menu/menu-item/onclick-menu-item.component.spec.ts b/src/app/shared/menu/menu-item/onclick-menu-item.component.spec.ts
new file mode 100644
index 0000000000..dd031a96e0
--- /dev/null
+++ b/src/app/shared/menu/menu-item/onclick-menu-item.component.spec.ts
@@ -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;
+ 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();
+ });
+});
diff --git a/src/app/shared/menu/menu-item/onclick-menu-item.component.ts b/src/app/shared/menu/menu-item/onclick-menu-item.component.ts
new file mode 100644
index 0000000000..95b896ed64
--- /dev/null
+++ b/src/app/shared/menu/menu-item/onclick-menu-item.component.ts
@@ -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;
+ }
+}
diff --git a/src/app/shared/menu/menu.module.ts b/src/app/shared/menu/menu.module.ts
index 736da5c267..7e900d18e6 100644
--- a/src/app/shared/menu/menu.module.ts
+++ b/src/app/shared/menu/menu.module.ts
@@ -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 = [
diff --git a/src/app/shared/services/route.service.ts b/src/app/shared/services/route.service.ts
index f500b18082..a68116a13a 100644
--- a/src/app/shared/services/route.service.ts
+++ b/src/app/shared/services/route.service.ts
@@ -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) {
}
+ /**
+ * Retrieves all query parameter values based on a parameter name
+ * @param paramName The name of the parameter to look for
+ */
getQueryParameterValues(paramName: string): Observable {
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 {
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 {
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 {
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 {
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 {
@@ -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))
});
}
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts
index 43a3a0cd77..d9fc667e23 100644
--- a/src/app/shared/shared.module.ts
+++ b/src/app/shared/shared.module.ts
@@ -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 = [
diff --git a/src/app/shared/testing/search-service-stub.ts b/src/app/shared/testing/search-service-stub.ts
index cbc0611a47..2a46e42ef5 100644
--- a/src/app/shared/testing/search-service-stub.ts
+++ b/src/app/shared/testing/search-service-stub.ts
@@ -40,4 +40,8 @@ export class SearchServiceStub {
getFilterLabels() {
return observableOf([]);
}
+
+ search() {
+ return observableOf({});
+ }
}
diff --git a/yarn.lock b/yarn.lock
index dd6298f480..a3a3b043c7 100644
--- a/yarn.lock
+++ b/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"