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 a1758d7339..a03a0de451 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,18 @@
-
{{'search.filters.filter.' + filter.name + '.head'| translate}}
-
- -
+
+
+ {{'search.filters.filter.' + filter.name + '.head'| translate}} +
+ + +
+
+ + +
diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index a500cc42d7..8d583a78af 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -34,6 +34,7 @@ import { SearchLabelComponent } from './search-labels/search-label/search-label. import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; import { ConfigurationSearchPageGuard } from './configuration-search-page.guard'; import { FilteredSearchPageComponent } from './filtered-search-page.component'; +import { SidebarFilterService } from "../shared/sidebar/filter/sidebar-filter.service"; const effects = [ SidebarEffects @@ -78,6 +79,7 @@ const components = [ declarations: components, providers: [ SidebarService, + SidebarFilterService, SearchFilterService, SearchFixedFilterService, ConfigurationSearchPageGuard, diff --git a/src/app/+search-page/search-settings/search-settings.component.html b/src/app/+search-page/search-settings/search-settings.component.html index d693196dae..d8878982b4 100644 --- a/src/app/+search-page/search-settings/search-settings.component.html +++ b/src/app/+search-page/search-settings/search-settings.component.html @@ -1,24 +1,32 @@ -

{{ 'search.sidebar.settings.title' | translate}}

-
-
{{ 'search.sidebar.settings.sort-by' | translate}}
- -
+

{{ 'search.sidebar.settings.title' | translate}}

-
-
{{ 'search.sidebar.settings.rpp' | translate}}
- -
-
\ No newline at end of file +
+ + + +
+ +
+ + + +
+ diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index fbdce542a8..f9dc813722 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -1,4 +1,4 @@ -import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store'; +import { ActionReducerMap, createSelector, MemoizedSelector, State } from '@ngrx/store'; import * as fromRouter from '@ngrx/router-store'; import { hostWindowReducer, HostWindowState } from './shared/host-window.reducer'; import { formReducer, FormState } from './shared/form/form.reducer'; @@ -6,6 +6,10 @@ import { SidebarState, sidebarReducer } from './shared/sidebar/sidebar.reducer'; +import { + SidebarFilterState, + sidebarFilterReducer, SidebarFiltersState +} from './shared/sidebar/filter/sidebar-filter.reducer'; import { filterReducer, SearchFiltersState @@ -37,7 +41,8 @@ export interface AppState { metadataRegistry: MetadataRegistryState; bitstreamFormats: BitstreamFormatRegistryState; notifications: NotificationsState; - searchSidebar: SidebarState; + sidebar: SidebarState; + sidebarFilter: SidebarFiltersState; searchFilter: SearchFiltersState; truncatable: TruncatablesState; cssVariables: CSSVariablesState; @@ -53,7 +58,8 @@ export const appReducers: ActionReducerMap = { metadataRegistry: metadataRegistryReducer, bitstreamFormats: bitstreamFormatReducer, notifications: notificationsReducer, - searchSidebar: sidebarReducer, + sidebar: sidebarReducer, + sidebarFilter: sidebarFilterReducer, searchFilter: filterReducer, truncatable: truncatableReducer, cssVariables: cssVariablesReducer, diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 5a1c2de26f..0d427be49c 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -148,6 +148,9 @@ import { PublicationGridElementComponent } from './object-grid/item-grid-element import { ItemTypeBadgeComponent } from './object-list/item-type-badge/item-type-badge.component'; import { ItemMetadataRepresentationListElementComponent } from './object-list/metadata-representation-list-element/item/item-metadata-representation-list-element.component'; import { PageWithSidebarComponent } from './sidebar/page-with-sidebar.component'; +import { SidebarDropdownComponent } from './sidebar/sidebar-dropdown.component'; +import { SidebarFilterComponent } from './sidebar/filter/sidebar-filter.component'; +import { SidebarFilterSelectedOptionComponent } from './sidebar/filter/sidebar-filter-selected-option.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -235,6 +238,9 @@ const COMPONENTS = [ PaginationComponent, SearchFormComponent, PageWithSidebarComponent, + SidebarDropdownComponent, + SidebarFilterComponent, + SidebarFilterSelectedOptionComponent, ThumbnailComponent, GridThumbnailComponent, UploaderComponent, diff --git a/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.html b/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.html new file mode 100644 index 0000000000..bbe0b93566 --- /dev/null +++ b/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.html @@ -0,0 +1,6 @@ + + + diff --git a/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.scss b/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.scss new file mode 100644 index 0000000000..b4e9cd340c --- /dev/null +++ b/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.scss @@ -0,0 +1,11 @@ +a { + color: $body-color; + + &:hover, &focus { + text-decoration: none; + } + + span.badge { + vertical-align: text-top; + } +} diff --git a/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.ts b/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.ts new file mode 100644 index 0000000000..5c80a9cd87 --- /dev/null +++ b/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.ts @@ -0,0 +1,15 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'ds-sidebar-filter-selected-option', + styleUrls: ['./sidebar-filter-selected-option.component.scss'], + templateUrl: './sidebar-filter-selected-option.component.html', +}) + +/** + * Represents a single selected option in a sidebar filter + */ +export class SidebarFilterSelectedOptionComponent { + @Input() label:string; + @Output() click:EventEmitter = new EventEmitter(); +} diff --git a/src/app/shared/sidebar/filter/sidebar-filter.actions.ts b/src/app/shared/sidebar/filter/sidebar-filter.actions.ts new file mode 100644 index 0000000000..2391274489 --- /dev/null +++ b/src/app/shared/sidebar/filter/sidebar-filter.actions.ts @@ -0,0 +1,74 @@ +import { Action } from '@ngrx/store'; + +import { type } from '../../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 SidebarFilterActionTypes = { + INITIALIZE: type('dspace/sidebar-filter/INITIALIZE'), + COLLAPSE: type('dspace/sidebar-filter/COLLAPSE'), + EXPAND: type('dspace/sidebar-filter/EXPAND'), + TOGGLE: type('dspace/sidebar-filter/TOGGLE'), +}; + +export class SidebarFilterAction implements Action { + /** + * Name of the filter the action is performed on, used to identify the filter + */ + filterName: string; + + /** + * Type of action that will be performed + */ + type; + + /** + * Initialize with the filter's name + * @param {string} name of the filter + */ + constructor(name: string) { + this.filterName = name; + } +} + +/* tslint:disable:max-classes-per-file */ +/** + * Used to initialize a filter + */ +export class FilterInitializeAction extends SidebarFilterAction { + type = SidebarFilterActionTypes.INITIALIZE; + initiallyExpanded; + + constructor(name:string, initiallyExpanded:boolean) { + super(name); + this.initiallyExpanded = initiallyExpanded; + } +} + +/** + * Used to collapse a filter + */ +export class FilterCollapseAction extends SidebarFilterAction { + type = SidebarFilterActionTypes.COLLAPSE; +} + +/** + * Used to expand a filter + */ +export class FilterExpandAction extends SidebarFilterAction { + type = SidebarFilterActionTypes.EXPAND; +} + +/** + * Used to collapse a filter when it's expanded and expand it when it's collapsed + */ +export class FilterToggleAction extends SidebarFilterAction { + type = SidebarFilterActionTypes.TOGGLE; +} +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/shared/sidebar/filter/sidebar-filter.component.html b/src/app/shared/sidebar/filter/sidebar-filter.component.html new file mode 100644 index 0000000000..b0209d9900 --- /dev/null +++ b/src/app/shared/sidebar/filter/sidebar-filter.component.html @@ -0,0 +1,24 @@ +
+
+
+ {{ label | translate }} +
+ + +
+ +
diff --git a/src/app/shared/sidebar/filter/sidebar-filter.component.scss b/src/app/shared/sidebar/filter/sidebar-filter.component.scss new file mode 100644 index 0000000000..68949f3450 --- /dev/null +++ b/src/app/shared/sidebar/filter/sidebar-filter.component.scss @@ -0,0 +1,12 @@ +:host .facet-filter { + border: 1px solid map-get($theme-colors, light); + cursor: pointer; + + .sidebar-filter-wrapper.closed { + overflow: hidden; + } + + .filter-toggle { + line-height: $line-height-base; + } +} diff --git a/src/app/shared/sidebar/filter/sidebar-filter.component.ts b/src/app/shared/sidebar/filter/sidebar-filter.component.ts new file mode 100644 index 0000000000..4d5f41d971 --- /dev/null +++ b/src/app/shared/sidebar/filter/sidebar-filter.component.ts @@ -0,0 +1,84 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Observable } from 'rxjs'; +import { SidebarFilterService } from './sidebar-filter.service'; +import { slide } from '../../animations/slide'; + +@Component({ + selector: 'ds-sidebar-filter', + styleUrls: ['./sidebar-filter.component.scss'], + templateUrl: './sidebar-filter.component.html', + animations: [slide], +}) +export class SidebarFilterComponent implements OnInit { + + @Input() name:string; + @Input() type:string; + @Input() label:string; + @Input() expanded = true; + @Input() selectedValues:Observable; + @Output() submitValue:EventEmitter = new EventEmitter(); + @Output() removeValue:EventEmitter = new EventEmitter(); + + /** + * True when the filter is 100% collapsed in the UI + */ + closed = true; + + /** + * Emits true when the filter is currently collapsed in the store + */ + collapsed$:Observable; + + constructor( + protected filterService:SidebarFilterService + ) { + } + + /** + * Changes the state for this filter to collapsed when it's expanded and to expanded it when it's collapsed + */ + toggle() { + this.filterService.toggle(this.name); + } + + /** + * Method to change this.collapsed to false when the slide animation ends and is sliding open + * @param event The animation event + */ + finishSlide(event:any):void { + if (event.fromState === 'collapsed') { + this.closed = false; + } + } + + /** + * Method to change this.collapsed to true when the slide animation starts and is sliding closed + * @param event The animation event + */ + startSlide(event:any):void { + if (event.toState === 'collapsed') { + this.closed = true; + } + } + + ngOnInit():void { + this.initializeFilter(); + this.collapsed$ = this.isCollapsed(); + } + + /** + * Sets the initial state of the filter + */ + initializeFilter() { + this.filterService.initializeFilter(this.name, this.expanded); + } + + /** + * 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 + */ + private isCollapsed():Observable { + return this.filterService.isCollapsed(this.name); + } + +} diff --git a/src/app/shared/sidebar/filter/sidebar-filter.reducer.ts b/src/app/shared/sidebar/filter/sidebar-filter.reducer.ts new file mode 100644 index 0000000000..d25737eaa9 --- /dev/null +++ b/src/app/shared/sidebar/filter/sidebar-filter.reducer.ts @@ -0,0 +1,70 @@ +import { + FilterInitializeAction, + SidebarFilterAction, + SidebarFilterActionTypes +} from './sidebar-filter.actions'; + +/** + * Interface that represents the state for a single filters + */ +export interface SidebarFilterState { + filterCollapsed:boolean, +} + +/** + * Interface that represents the state for all available filters + */ +export interface SidebarFiltersState { + [name:string]:SidebarFilterState +} + +const initialState:SidebarFiltersState = Object.create(null); + +/** + * Performs a filter action on the current state + * @param {SidebarFiltersState} state The state before the action is performed + * @param {SidebarFilterAction} action The action that should be performed + * @returns {SidebarFiltersState} The state after the action is performed + */ +export function sidebarFilterReducer(state = initialState, action:SidebarFilterAction):SidebarFiltersState { + + switch (action.type) { + + case SidebarFilterActionTypes.INITIALIZE: { + const initAction = (action as FilterInitializeAction); + return Object.assign({}, state, { + [action.filterName]: { + filterCollapsed: !initAction.initiallyExpanded, + } + }); + } + + case SidebarFilterActionTypes.COLLAPSE: { + return Object.assign({}, state, { + [action.filterName]: { + filterCollapsed: true, + } + }); + } + + case SidebarFilterActionTypes.EXPAND: { + return Object.assign({}, state, { + [action.filterName]: { + filterCollapsed: false, + } + }); + } + + case SidebarFilterActionTypes.TOGGLE: { + return Object.assign({}, state, { + [action.filterName]: { + filterCollapsed: !state[action.filterName].filterCollapsed, + } + }); + } + + default: { + return state; + } + } +} diff --git a/src/app/shared/sidebar/filter/sidebar-filter.service.ts b/src/app/shared/sidebar/filter/sidebar-filter.service.ts new file mode 100644 index 0000000000..b08c7a8b73 --- /dev/null +++ b/src/app/shared/sidebar/filter/sidebar-filter.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@angular/core'; +import { + FilterCollapseAction, + FilterExpandAction, FilterInitializeAction, + FilterToggleAction +} from './sidebar-filter.actions'; +import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; +import { SidebarFiltersState, SidebarFilterState } from './sidebar-filter.reducer'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; +import { hasValue } from '../../empty.util'; + +@Injectable() +export class SidebarFilterService { + + constructor(private store:Store) { + } + + /** + * Dispatches an initialize action to the store for a given filter + * @param {string} filter The filter for which the action is dispatched + * @param {boolean} expanded If the filter should be open from the start + */ + public initializeFilter(filter:string, expanded:boolean):void { + this.store.dispatch(new FilterInitializeAction(filter, expanded)); + } + + /** + * Dispatches a collapse action to the store for a given filter + * @param {string} filterName The filter for which the action is dispatched + */ + public collapse(filterName:string):void { + this.store.dispatch(new FilterCollapseAction(filterName)); + } + + /** + * Dispatches an expand action to the store for a given filter + * @param {string} filterName The filter for which the action is dispatched + */ + public expand(filterName:string):void { + this.store.dispatch(new FilterExpandAction(filterName)); + } + + /** + * Dispatches a toggle action to the store for a given filter + * @param {string} filterName The filter for which the action is dispatched + */ + public toggle(filterName:string):void { + this.store.dispatch(new FilterToggleAction(filterName)); + } + + /** + * Checks if the state of a given filter is currently collapsed or not + * @param {string} filterName The filtername for which the collapsed state is checked + * @returns {Observable} Emits the current collapsed state of the given filter, if it's unavailable, return false + */ + isCollapsed(filterName:string):Observable { + return this.store.pipe( + select(filterByNameSelector(filterName)), + map((object:SidebarFilterState) => { + if (object) { + return object.filterCollapsed; + } else { + return false; + } + }), + distinctUntilChanged() + ); + } + +} + +const filterStateSelector = (state:SidebarFiltersState) => state.sidebarFilter; + +function filterByNameSelector(name:string):MemoizedSelector { + return keySelector(name); +} + +export function keySelector(key:string):MemoizedSelector { + return createSelector(filterStateSelector, (state:SidebarFilterState) => { + if (hasValue(state)) { + return state[key]; + } else { + return undefined; + } + }); +} diff --git a/src/app/shared/sidebar/page-with-sidebar.component.spec.ts b/src/app/shared/sidebar/page-with-sidebar.component.spec.ts index dfe035d2be..77f59090ab 100644 --- a/src/app/shared/sidebar/page-with-sidebar.component.spec.ts +++ b/src/app/shared/sidebar/page-with-sidebar.component.spec.ts @@ -2,8 +2,8 @@ import { By } from '@angular/platform-browser'; import { of as observableOf } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PageWithSidebarComponent } from './page-with-sidebar.component'; -import { SidebarService } from './sidebar/sidebar.service'; -import { HostWindowService } from '../shared/host-window.service'; +import { SidebarService } from './sidebar.service'; +import { HostWindowService } from '../host-window.service'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; describe('PageWithSidebarComponent', () => { diff --git a/src/app/shared/sidebar/sidebar-dropdown.component.html b/src/app/shared/sidebar/sidebar-dropdown.component.html new file mode 100644 index 0000000000..0c2a1c05d2 --- /dev/null +++ b/src/app/shared/sidebar/sidebar-dropdown.component.html @@ -0,0 +1,6 @@ +
+
+ +
diff --git a/src/app/shared/sidebar/sidebar-dropdown.component.scss b/src/app/shared/sidebar/sidebar-dropdown.component.scss new file mode 100644 index 0000000000..1c025095dd --- /dev/null +++ b/src/app/shared/sidebar/sidebar-dropdown.component.scss @@ -0,0 +1,3 @@ +.setting-option { + border: 1px solid map-get($theme-colors, light); +} diff --git a/src/app/shared/sidebar/sidebar-dropdown.component.ts b/src/app/shared/sidebar/sidebar-dropdown.component.ts new file mode 100644 index 0000000000..d4c996157c --- /dev/null +++ b/src/app/shared/sidebar/sidebar-dropdown.component.ts @@ -0,0 +1,12 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'ds-sidebar-dropdown', + styleUrls: ['./sidebar-dropdown.component.scss'], + templateUrl: './sidebar-dropdown.component.html', +}) +export class SidebarDropdownComponent { + @Input() id:string; + @Input() label:string; + @Output() change:EventEmitter = new EventEmitter(); +} diff --git a/src/app/shared/sidebar/sidebar.reducer.ts b/src/app/shared/sidebar/sidebar.reducer.ts index 3c3dc63b0d..05e7d38d48 100644 --- a/src/app/shared/sidebar/sidebar.reducer.ts +++ b/src/app/shared/sidebar/sidebar.reducer.ts @@ -12,7 +12,7 @@ const initialState: SidebarState = { }; /** - * Performs a search sidebar action on the current state + * Performs a sidebar action on the current state * @param {SidebarState} state The state before the action is performed * @param {SidebarAction} action The action that should be performed * @returns {SidebarState} The state after the action is performed diff --git a/src/app/shared/sidebar/sidebar.service.ts b/src/app/shared/sidebar/sidebar.service.ts index 2e3303887b..125fb5b629 100644 --- a/src/app/shared/sidebar/sidebar.service.ts +++ b/src/app/shared/sidebar/sidebar.service.ts @@ -7,7 +7,7 @@ import { AppState } from '../../app.reducer'; import { HostWindowService } from '../host-window.service'; import { map } from 'rxjs/operators'; -const sidebarStateSelector = (state: AppState) => state.searchSidebar; +const sidebarStateSelector = (state: AppState) => state.sidebar; const sidebarCollapsedSelector = createSelector(sidebarStateSelector, (sidebar: SidebarState) => sidebar.sidebarCollapsed); /**