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}} - +
- - - - {{value.value}} - - {{value.count}} - - - +
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 @@ +
+ + +
\ 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"