diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html index 04d3b13e43..c8e33bf565 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -1,10 +1,8 @@
+
- - -
-
- + + (toggleSidebar)="toggle()">
- -
{ +fdescribe('SearchPageComponent', () => { let comp: SearchPageComponent; let fixture: ComponentFixture; let searchServiceObject: SearchService; @@ -39,14 +43,15 @@ describe('SearchPageComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - // imports: [ SearchPageModule ], - declarations: [SearchPageComponent], + imports: [ SearchPageModule ], + // declarations: [SearchPageComponent], providers: [ { provide: SearchService, useValue: searchServiceStub }, { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: CommunityDataService, useValue: communityDataServiceStub }, - { provide: Router, useClass: RouterStub } - ], + { provide: Router, useClass: RouterStub }, + { provide: Store, useClass: {} } + ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); })); diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index 01569711ab..a4a37162e2 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -6,11 +6,20 @@ import { SearchResult } from './search-result.model'; import { DSpaceObject } from '../core/shared/dspace-object.model'; import { SortOptions } from '../core/cache/models/sort-options.model'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { ViewModeSwitchComponent } from '../shared/view-mode-switch/view-mode-switch.component'; import { SearchOptions } from './search-options.model'; import { CommunityDataService } from '../core/data/community-data.service'; import { isNotEmpty } from '../shared/empty.util'; import { Community } from '../core/shared/community.model'; +import { createSelector, Store } from '@ngrx/store'; +import { AppState } from '../app.reducer'; +import { Observable } from 'rxjs/Observable'; +import { SearchSidebarState } from './search-sidebar/search-sidebar.reducer'; +import { SearchSidebarToggleAction } from './search-sidebar/search-sidebar.actions'; +import { slideInOut } from '../shared/animations/slide'; +import { HostWindowService } from '../shared/host-window.service'; + +const sidebarStateSelector = (state: AppState) => state.searchSidebar; +const sidebarCollapsedSelector = createSelector(sidebarStateSelector, (sidebar: SearchSidebarState) => sidebar.sidebarCollapsed); /** * This component renders a simple item page. @@ -22,6 +31,7 @@ import { Community } from '../core/shared/community.model'; selector: 'ds-search-page', styleUrls: ['./search-page.component.scss'], templateUrl: './search-page.component.html', + animations: [slideInOut] }) export class SearchPageComponent implements OnInit, OnDestroy { @@ -34,14 +44,14 @@ export class SearchPageComponent implements OnInit, OnDestroy { currentParams = {}; searchOptions: SearchOptions; scopeList: RemoteData; - isSidebarActive = false; - isListView = true; + isSidebarCollapsed: Observable; + isMobileView: Observable; - constructor( - private service: SearchService, - private route: ActivatedRoute, - private communityService: CommunityDataService - ) { + constructor(private service: SearchService, + private route: ActivatedRoute, + private communityService: CommunityDataService, + private store: Store, + private hostWindowService: HostWindowService) { this.scopeList = communityService.findAll(); // Initial pagination config const pagination: PaginationComponentOptions = new PaginationComponentOptions(); @@ -50,9 +60,14 @@ export class SearchPageComponent implements OnInit, OnDestroy { pagination.pageSize = 10; const sort: SortOptions = new SortOptions(); this.searchOptions = { pagination: pagination, sort: sort }; + this.isMobileView = Observable.combineLatest( + this.hostWindowService.isXs(), + this.hostWindowService.isSm(), + (isXs, isSm) => isXs || isSm); } ngOnInit(): void { + this.isSidebarCollapsed = this.store.select(sidebarCollapsedSelector); this.sub = this.route .queryParams .subscribe((params) => { @@ -93,11 +108,7 @@ export class SearchPageComponent implements OnInit, OnDestroy { this.sub.unsubscribe(); } - setSidebarActive(show: boolean) { - this.isSidebarActive = show; - } - - setListView(isList: boolean) { - this.isListView = isList; + public toggle(): void { + this.store.dispatch(new SearchSidebarToggleAction()); } } diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index 0df318cbdc..6345582de9 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -19,8 +19,6 @@ import { LayoutControlsComponent } from './layout-controls/layout-controls.compo imports: [ SearchPageRoutingModule, CommonModule, - TranslateModule, - RouterModule, SharedModule ], declarations: [ diff --git a/src/app/+search-page/search-sidebar/search-sidebar.actions.ts b/src/app/+search-page/search-sidebar/search-sidebar.actions.ts new file mode 100644 index 0000000000..f393bc10b3 --- /dev/null +++ b/src/app/+search-page/search-sidebar/search-sidebar.actions.ts @@ -0,0 +1,40 @@ +import { Action } from '@ngrx/store'; + +import { type } from '../../shared/ngrx/type'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const SearchSidebarActionTypes = { + COLLAPSE: type('dspace/search-sidebar/COLLAPSE'), + EXPAND: type('dspace/search-sidebar/EXPAND'), + TOGGLE: type('dspace/search-sidebar/TOGGLE') +}; + +/* tslint:disable:max-classes-per-file */ +export class SearchSidebarCollapseAction implements Action { + type = SearchSidebarActionTypes.COLLAPSE; +} + +export class SearchSidebarExpandAction implements Action { + type = SearchSidebarActionTypes.EXPAND; +} + +export class SearchSidebarToggleAction implements Action { + type = SearchSidebarActionTypes.TOGGLE; +} +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types + */ +export type SearchSidebarAction + = SearchSidebarCollapseAction + | SearchSidebarExpandAction + | SearchSidebarToggleAction diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.html b/src/app/+search-page/search-sidebar/search-sidebar.component.html index 9ba8677313..9c0b7b5f3d 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.html +++ b/src/app/+search-page/search-sidebar/search-sidebar.component.html @@ -7,6 +7,7 @@
+ Place filters and other search config here
\ No newline at end of file diff --git a/src/app/+search-page/search-sidebar/search-sidebar.effects.ts b/src/app/+search-page/search-sidebar/search-sidebar.effects.ts new file mode 100644 index 0000000000..c6f04dbcb1 --- /dev/null +++ b/src/app/+search-page/search-sidebar/search-sidebar.effects.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@angular/core'; +import { Effect, Actions } from '@ngrx/effects' +import * as fromRouter from '@ngrx/router-store'; + +import { HostWindowActionTypes } from '../../shared/host-window.actions'; +import { SearchSidebarCollapseAction } from './search-sidebar.actions'; + +@Injectable() +export class SearchSidebarEffects { + + @Effect() resize$ = this.actions$ + .ofType(HostWindowActionTypes.RESIZE) + .map(() => new SearchSidebarCollapseAction()); + + @Effect() routeChange$ = this.actions$ + .ofType(fromRouter.ROUTER_NAVIGATION) + .map(() => new SearchSidebarCollapseAction()); + + constructor(private actions$: Actions) { + + } + +} diff --git a/src/app/+search-page/search-sidebar/search-sidebar.reducer.spec.ts b/src/app/+search-page/search-sidebar/search-sidebar.reducer.spec.ts new file mode 100644 index 0000000000..0976d2f3e8 --- /dev/null +++ b/src/app/+search-page/search-sidebar/search-sidebar.reducer.spec.ts @@ -0,0 +1,91 @@ +import * as deepFreeze from 'deep-freeze'; + +import { sidebarReducer } from './search-sidebar.reducer'; +import { + SearchSidebarCollapseAction, SearchSidebarExpandAction, + SearchSidebarToggleAction +} from './search-sidebar.actions'; +import { async, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +class NullAction extends SearchSidebarCollapseAction { + type = null; + + constructor() { + super(); + } +} + +describe('sidebarReducer', () => { + + it('should return the current state when no valid actions have been made', () => { + const state = { sidebarCollapsed: false }; + const action = new NullAction(); + const newState = sidebarReducer(state, action); + + expect(newState).toEqual(state); + }); + + it('should start with sidebarCollapsed = true', () => { + const action = new NullAction(); + const initialState = sidebarReducer(undefined, action); + + // The search sidebar starts collapsed + expect(initialState.sidebarCollapsed).toEqual(true); + }); + + it('should set sidebarCollapsed to true in response to the COLLAPSE action', () => { + const state = { sidebarCollapsed: false }; + const action = new SearchSidebarCollapseAction(); + const newState = sidebarReducer(state, action); + + expect(newState.sidebarCollapsed).toEqual(true); + }); + + it('should perform the COLLAPSE action without affecting the previous state', () => { + const state = { sidebarCollapsed: false }; + deepFreeze([state]); + + const action = new SearchSidebarCollapseAction(); + sidebarReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + }); + + it('should set sidebarCollapsed to false in response to the EXPAND action', () => { + const state = { sidebarCollapsed: true }; + const action = new SearchSidebarExpandAction(); + const newState = sidebarReducer(state, action); + + expect(newState.sidebarCollapsed).toEqual(false); + }); + + it('should perform the EXPAND action without affecting the previous state', () => { + const state = { sidebarCollapsed: true }; + deepFreeze([state]); + + const action = new SearchSidebarExpandAction(); + sidebarReducer(state, action); + }); + + it('should flip the value of sidebarCollapsed in response to the TOGGLE action', () => { + const state1 = { sidebarCollapsed: true }; + const action = new SearchSidebarToggleAction(); + + const state2 = sidebarReducer(state1, action); + const state3 = sidebarReducer(state2, action); + + expect(state2.sidebarCollapsed).toEqual(false); + expect(state3.sidebarCollapsed).toEqual(true); + }); + + it('should perform the TOGGLE action without affecting the previous state', () => { + const state = { sidebarCollapsed: true }; + deepFreeze([state]); + + const action = new SearchSidebarToggleAction(); + sidebarReducer(state, action); + }); + +}); diff --git a/src/app/+search-page/search-sidebar/search-sidebar.reducer.ts b/src/app/+search-page/search-sidebar/search-sidebar.reducer.ts new file mode 100644 index 0000000000..81d9069238 --- /dev/null +++ b/src/app/+search-page/search-sidebar/search-sidebar.reducer.ts @@ -0,0 +1,38 @@ +import { SearchSidebarAction, SearchSidebarActionTypes } from './search-sidebar.actions'; + +export interface SearchSidebarState { + sidebarCollapsed: boolean; +} + +const initialState: SearchSidebarState = { + sidebarCollapsed: true +}; + +export function sidebarReducer(state = initialState, action: SearchSidebarAction): SearchSidebarState { + switch (action.type) { + + case SearchSidebarActionTypes.COLLAPSE: { + return Object.assign({}, state, { + sidebarCollapsed: true + }); + } + + case SearchSidebarActionTypes.EXPAND: { + return Object.assign({}, state, { + sidebarCollapsed: false + }); + + } + + case SearchSidebarActionTypes.TOGGLE: { + return Object.assign({}, state, { + sidebarCollapsed: !state.sidebarCollapsed + }); + + } + + default: { + return state; + } + } +} diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 221c1c37d1..fe9ab3d7db 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -62,12 +62,17 @@ export class AppComponent implements OnInit { const env: string = this.config.production ? 'Production' : 'Development'; const color: string = this.config.production ? 'red' : 'green'; console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); + this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); } @HostListener('window:resize', ['$event']) private onResize(event): void { + this.dispatchWindowSize(event.target.innerWidth, event.target.innerHeight); + } + + private dispatchWindowSize(width, height): void { this.store.dispatch( - new HostWindowResizeAction(event.target.innerWidth, event.target.innerHeight) + new HostWindowResizeAction(width, height) ); } diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index baa3250549..b01fd62f60 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -3,15 +3,21 @@ import * as fromRouter from '@ngrx/router-store'; import { headerReducer, HeaderState } from './header/header.reducer'; import { hostWindowReducer, HostWindowState } from './shared/host-window.reducer'; +import { + SearchSidebarState, + sidebarReducer +} from './+search-page/search-sidebar/search-sidebar.reducer'; export interface AppState { router: fromRouter.RouterReducerState; hostWindow: HostWindowState; header: HeaderState; + searchSidebar: SearchSidebarState; } export const appReducers: ActionReducerMap = { router: fromRouter.routerReducer, hostWindow: hostWindowReducer, - header: headerReducer + header: headerReducer, + searchSidebar: sidebarReducer, }; diff --git a/src/app/shared/animations/slide.ts b/src/app/shared/animations/slide.ts new file mode 100644 index 0000000000..e4a1fcbb59 --- /dev/null +++ b/src/app/shared/animations/slide.ts @@ -0,0 +1,16 @@ +import { animate, state, transition, trigger, style } from '@angular/animations'; + +export const slideInOut = trigger('slideInOut', [ + + /* + state('expanded', style({ right: '100%' })); + + state('collapsed', style({ right: 0 })); +*/ + + state('expanded', style({ left: '100%' })), + + state('collapsed', style({ left: 0 })), + + transition('expanded <=> collapsed', animate(250)), +]); diff --git a/src/app/shared/mocks/mock-host-window-service.ts b/src/app/shared/mocks/mock-host-window-service.ts index 104e712682..e7d108407b 100644 --- a/src/app/shared/mocks/mock-host-window-service.ts +++ b/src/app/shared/mocks/mock-host-window-service.ts @@ -16,4 +16,8 @@ export class MockHostWindowService { isXs(): Observable { return Observable.of(this.width < 576); } + + isSm(): Observable { + return Observable.of(this.width < 768); + } } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 5b6146b7a4..6e05647d53 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -30,6 +30,7 @@ import { SearchResultListElementComponent } from '../object-list/search-result-l import { SearchFormComponent } from './search-form/search-form.component'; import { WrapperListElementComponent } from '../object-list/wrapper-list-element/wrapper-list-element.component'; import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component'; +import { ScrollAndStickDirective } from './utils/scroll-and-stick.directive'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -66,6 +67,10 @@ const COMPONENTS = [ ViewModeSwitchComponent ]; +const DIRECTIVES = [ + ScrollAndStickDirective, +]; + const ENTRY_COMPONENTS = [ // put shared entry components (components that are created dynamically) here CollectionListElementComponent, @@ -81,12 +86,14 @@ const ENTRY_COMPONENTS = [ declarations: [ ...PIPES, ...COMPONENTS, - ...ENTRY_COMPONENTS + ...ENTRY_COMPONENTS, + ...DIRECTIVES ], exports: [ ...MODULES, ...PIPES, - ...COMPONENTS + ...COMPONENTS, + ...DIRECTIVES ], entryComponents: [ ...ENTRY_COMPONENTS diff --git a/src/app/shared/utils/scroll-and-stick.directive.ts b/src/app/shared/utils/scroll-and-stick.directive.ts new file mode 100644 index 0000000000..4ec99d8db6 --- /dev/null +++ b/src/app/shared/utils/scroll-and-stick.directive.ts @@ -0,0 +1,40 @@ + +import { NativeWindowRef, NativeWindowService } from '../window.service'; +import { Observable } from 'rxjs/Observable'; +import { AfterViewInit, Directive, ElementRef, Inject } from '@angular/core'; + +@Directive({ + selector: '[dsStick]' +}) +export class ScrollAndStickDirective implements AfterViewInit { + + private initialY: number; + + constructor(private _element: ElementRef, @Inject(NativeWindowService) private _window: NativeWindowRef) { + this.subscribeForScrollEvent(); + } + + ngAfterViewInit(): void { + this.initialY = this._element.nativeElement.getBoundingClientRect().top; + } + + subscribeForScrollEvent() { + + const obs = Observable.fromEvent(window, 'scroll'); + + obs.subscribe((e) => this.handleScrollEvent(e)); + } + + handleScrollEvent(e) { + + if (this._window.nativeWindow.pageYOffset >= this.initialY) { + + this._element.nativeElement.classList.add('stick'); + + } else { + + this._element.nativeElement.classList.remove('stick'); + + } + } +}