44988: added reducer, angular animation, tests...

This commit is contained in:
Lotte Hofstede
2017-10-30 17:04:52 +01:00
parent bbe15e8e67
commit 7d5dad94a1
16 changed files with 334 additions and 80 deletions

View File

@@ -1,10 +1,8 @@
<div class="search-page">
<ds-search-sidebar dsStick *ngIf="!(isMobileView | async)" class="col-3 sidebar-md-fixed" id="search-sidebar"
resultCount="{{(results.pageInfo | async)?.totalElements}}"
(toggleSidebar)="toggle()"></ds-search-sidebar>
<div id="search-header" class="row">
<ds-layout-controls class="col-md-3 d-none d-md-block sidebar-md-fixed"
[isList]="isListView"
(toggleList)="setListView($event)"></ds-layout-controls>
<ds-view-mode-switch></ds-view-mode-switch>
<ds-search-form id="search-form" class="col-12 col-md-9 ml-md-auto"
[query]="query"
[scope]="scopeObject?.payload | async"
@@ -13,20 +11,18 @@
</ds-search-form>
</div>
<div class="row">
<div id="search-body" class="row-offcanvas row-offcanvas-left" [ngClass]="{'active': isSidebarActive}">
<ds-search-sidebar class="col-12 col-md-3 sidebar-md-fixed" id="search-sidebar"
<div id="search-body" class="row-offcanvas row-offcanvas-left" [@slideInOut]="(isSidebarCollapsed | async) ? 'collapsed' : 'expanded'">
<ds-search-sidebar *ngIf="(isMobileView | async)" class="col-12" id="search-sidebar-xs"
resultCount="{{(results.pageInfo | async)?.totalElements}}"
(toggleSidebar)="setSidebarActive($event)"></ds-search-sidebar>
(toggleSidebar)="toggle()"></ds-search-sidebar>
<div id="search-content" class="col-12 col-md-9 ml-md-auto">
<div class="d-block d-md-none search-controls clearfix">
<ds-view-mode-switch></ds-view-mode-switch>
<ds-layout-controls [isList]="isListView"
(toggleList)="setListView($event)"></ds-layout-controls>
<button (click)="setSidebarActive(true)" aria-controls="#search-body"
<button (click)="toggle()" aria-controls="#search-body"
class="btn btn-outline-primary float-right"><i
class="fa fa-sliders"></i> {{"search.sidebar.open" |
translate}}
class="fa fa-sliders"></i> {{"search.sidebar.open"
| translate}}
</button>
</div>
<ds-search-results [searchResults]="results"

View File

@@ -22,63 +22,36 @@
}
@include media-breakpoint-down(sm) {
position: relative;
-webkit-transition: all .25s ease-out;
-o-transition: all .25s ease-out;
transition: all .25s ease-out;
&.row-offcanvas {
position: relative;
-webkit-transition: all .25s ease-out;
-o-transition: all .25s ease-out;
transition: all .25s ease-out;
}
&.row-offcanvas-right {
right: 0;
&.row-offcanvas-right #search-sidebar-xs {
right: -100%;
}
&.row-offcanvas-left {
left: 0;
&.row-offcanvas-left #search-sidebar-xs {
left: -100%;
}
&.row-offcanvas-right
#search-sidebar {
right: -100%; /* 12 columns */
}
&.row-offcanvas-right.active
#search-sidebar {
right: -100%; /* 6 columns */
}
&.row-offcanvas-left
#search-sidebar {
left: -100%; /* 12 columns */
}
&.row-offcanvas-left.active
#search-sidebar {
left: -100%; /* 6 columns */
}
&.row-offcanvas-right.active {
right: 100%; /* 6 columns */
}
&.row-offcanvas-left.active {
left: 100%; /* 6 columns */
}
#search-sidebar {
#search-sidebar-xs {
position: absolute;
top: 0;
width: 100%; /* 6 columns */
width: 100%;
}
}
}
.sidebar-md-fixed {
@include media-breakpoint-up(md) {
position: absolute;
margin-top: -$content-spacing;
padding-top: $content-spacing;
&.stick {
top: 0;
margin-top: 0px;
position: fixed;
}
}
}

View File

@@ -1,4 +1,5 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
@@ -8,8 +9,11 @@ import { SearchService } from './search-service/search.service';
import { Community } from '../core/shared/community.model';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { SearchPageModule } from './search-page.module';
import { Store } from '@ngrx/store';
describe('SearchPageComponent', () => {
fdescribe('SearchPageComponent', () => {
let comp: SearchPageComponent;
let fixture: ComponentFixture<SearchPageComponent>;
let searchServiceObject: SearchService;
@@ -39,13 +43,14 @@ 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();

View File

@@ -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<Community[]>;
isSidebarActive = false;
isListView = true;
isSidebarCollapsed: Observable<boolean>;
isMobileView: Observable<boolean>;
constructor(
private service: SearchService,
constructor(private service: SearchService,
private route: ActivatedRoute,
private communityService: CommunityDataService
) {
private communityService: CommunityDataService,
private store: Store<AppState>,
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());
}
}

View File

@@ -19,8 +19,6 @@ import { LayoutControlsComponent } from './layout-controls/layout-controls.compo
imports: [
SearchPageRoutingModule,
CommonModule,
TranslateModule,
RouterModule,
SharedModule
],
declarations: [

View File

@@ -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

View File

@@ -7,6 +7,7 @@
</button>
</div>
<div id="search-sidebar-content">
<ds-view-mode-switch class="d-none d-md-block"></ds-view-mode-switch>
Place filters and other search config here
</div>
</div>

View File

@@ -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) {
}
}

View File

@@ -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);
});
});

View File

@@ -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;
}
}
}

View File

@@ -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)
);
}

View File

@@ -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<AppState> = {
router: fromRouter.routerReducer,
hostWindow: hostWindowReducer,
header: headerReducer
header: headerReducer,
searchSidebar: sidebarReducer,
};

View File

@@ -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)),
]);

View File

@@ -16,4 +16,8 @@ export class MockHostWindowService {
isXs(): Observable<boolean> {
return Observable.of(this.width < 576);
}
isSm(): Observable<boolean> {
return Observable.of(this.width < 768);
}
}

View File

@@ -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

View File

@@ -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');
}
}
}