diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.html index 074c5700d7..4ab9e172bd 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.html @@ -1,5 +1,5 @@
-
+
-
- - -
+
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.scss index 595b2aefb8..2212dadb6a 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.scss +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.scss @@ -2,11 +2,9 @@ @import '../../../../../styles/mixins.scss'; .filters { - margin-top: $spacer/2; - margin-bottom: $spacer/2; a { color: $body-color; - &:hover { + &:hover, &focus { text-decoration: none; } } @@ -15,4 +13,5 @@ text-decoration: underline; cursor: pointer; } -} \ No newline at end of file +} + 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 5f8111c87b..e722702f88 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 @@ -1,4 +1,12 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { + Component, + ElementRef, + Input, + OnDestroy, + OnInit, QueryList, + ViewChild, + ViewChildren +} from '@angular/core'; import { FacetValue } from '../../../search-service/facet-value.model'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; import { Router } from '@angular/router'; @@ -11,6 +19,7 @@ import { SearchService } from '../../../search-service/search.service'; import { SearchOptions } from '../../../search-options.model'; import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { Subscription } from 'rxjs/Subscription'; +import { EmphasizePipe } from '../../../../shared/utils/emphasize.pipe'; /** * This component renders a simple item page. @@ -34,6 +43,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { filter: string; pageChange = false; sub: Subscription; + filterSearchResults: Observable = Observable.of([]); constructor(private searchService: SearchService, private filterService: SearchFilterService, private router: Router) { } @@ -94,11 +104,17 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { }); this.filter = ''; } + this.filterSearchResults = Observable.of([]); + } + + clickFilter(data: string) { + this.onSubmit({ [this.filterConfig.paramName]: data }); } hasValue(o: any): boolean { return hasValue(o); } + getRemoveParams(value: string) { return { [this.filterConfig.paramName]: this.selectedValues.filter((v) => v !== value), @@ -122,4 +138,26 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { this.sub.unsubscribe(); } } + + findSuggestions(data): void { + if (isNotEmpty(data)) { + this.filterService.getSearchOptions().first().subscribe( + (options) => { + this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase()) + .first() + .map( + (rd: RemoteData>) => { + return rd.payload.page.map((facet) => facet.value) + } + ); + } + ) + } else { + this.filterSearchResults = Observable.of([]); + } + } + + getDisplayValue(value: string, searchValue: string) { + return new EmphasizePipe().transform(value, searchValue); + } } diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-filter.component.scss index f694e9e167..65b2ccfff6 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.scss +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.scss @@ -3,6 +3,9 @@ :host { border: 1px solid map-get($theme-colors, light); + >div { + position: relative; + } .search-filter-wrapper { overflow: hidden; } diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index b51a9c834d..774d2287e2 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -175,13 +175,18 @@ export class SearchService implements OnDestroy { return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, facetConfigObs); } - getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions): Observable>> { + getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions, filterQuery?: string): Observable>> { const requestObs = this.halService.getEndpoint(this.facetValueLinkPathPrefix + filterConfig.name).pipe( map((url: string) => { const args: string[] = [`page=${valuePage - 1}`, `size=${filterConfig.pageSize}`]; + if (hasValue(filterQuery)) { + // args.push(`${filterConfig.paramName}=${filterQuery},query`); + args.push(`prefix=${filterQuery}`); + } if (hasValue(searchOptions)) { url = searchOptions.toRestUrl(url, args); } + const request = new GetRequest(this.requestService.generateRequestId(), url); return Object.assign(request, { getResponseParser(): GenericConstructor { diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index c7456aa2f9..ebefbe4ba4 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -16,7 +16,7 @@ export class SearchResponseParsingService implements ResponseParsingService { } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const payload = data.payload; + const payload = data.payload._embedded.searchResult; const hitHighlights = payload._embedded.objects .map((object) => object.hitHighlights) .map((hhObject) => { diff --git a/src/app/shared/input-suggestions/input-suggestions.component.html b/src/app/shared/input-suggestions/input-suggestions.component.html new file mode 100644 index 0000000000..26bb975b5e --- /dev/null +++ b/src/app/shared/input-suggestions/input-suggestions.component.html @@ -0,0 +1,21 @@ +
+ + + +
\ No newline at end of file diff --git a/src/app/shared/input-suggestions/input-suggestions.component.scss b/src/app/shared/input-suggestions/input-suggestions.component.scss new file mode 100644 index 0000000000..ceda9c75b5 --- /dev/null +++ b/src/app/shared/input-suggestions/input-suggestions.component.scss @@ -0,0 +1,7 @@ +.autocomplete { + width: 100%; + .dropdown-item { + white-space: normal; + word-break: break-word; + } +} \ No newline at end of file diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts new file mode 100644 index 0000000000..166cfd71d2 --- /dev/null +++ b/src/app/shared/input-suggestions/input-suggestions.component.ts @@ -0,0 +1,95 @@ +import { + Component, + ElementRef, EventEmitter, + Input, + Output, + QueryList, SimpleChanges, + ViewChild, + ViewChildren +} from '@angular/core'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; +import { hasValue, isNotEmpty } from '../empty.util'; + +/** + * 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. + */ + +@Component({ + selector: 'ds-input-suggestions', + styleUrls: ['./input-suggestions.component.scss'], + templateUrl: './input-suggestions.component.html' +}) + +export class InputSuggestionsComponent { + @Input() suggestions: string[] = []; + @Input() debounceTime = 500; + @Input() placeholder = ''; + @Input() action; + @Input() name; + @Input() ngModel; + @Output() ngModelChange = new EventEmitter(); + @Output() submitSuggestion = new EventEmitter(); + @Output() clickSuggestion = new EventEmitter(); + @Output() findSuggestions = new EventEmitter(); + show = new BehaviorSubject(false); + selectedIndex = -1; + @ViewChild('inputField') queryInput: ElementRef; + @ViewChildren('suggestion') resultViews: QueryList; + @Input() getDisplayValue: (value: string, query: string) => string = (value: string, query: string) => value; + + ngOnChanges(changes: SimpleChanges) { + if (hasValue(changes.suggestions)) { + this.show.next(isNotEmpty(changes.suggestions.currentValue)); + } + } + + shiftFocusUp(event: KeyboardEvent) { + event.preventDefault(); + if (this.selectedIndex > 0) { + this.selectedIndex--; + this.selectedIndex = (this.selectedIndex + this.resultViews.length) % this.resultViews.length; // Prevent negative modulo outcome + } else { + this.selectedIndex = this.resultViews.length - 1; + } + this.changeFocus(); + } + + shiftFocusDown(event: KeyboardEvent) { + event.preventDefault(); + if (this.selectedIndex >= 0) { + this.selectedIndex++; + this.selectedIndex %= this.resultViews.length; + } else { + this.selectedIndex = 0; + } + this.changeFocus(); + } + + changeFocus() { + if (this.resultViews.length > 0) { + this.resultViews.toArray()[this.selectedIndex].nativeElement.focus(); + } + } + + onKeydown(event: KeyboardEvent) { + if (event.key !== 'Enter') { + this.queryInput.nativeElement.focus(); + } + } + + close() { + this.show.next(false); + } + + isNotEmpty(data) { + return isNotEmpty(data); + } + + onClickSuggestion(data) { + this.clickSuggestion.emit(data); + return false; + } + +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 79ddd8680f..2e8b40e5a5 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -40,14 +40,16 @@ import { SearchResultGridElementComponent } from './object-grid/search-result-gr import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component'; import { GridThumbnailComponent } from './object-grid/grid-thumbnail/grid-thumbnail.component'; import { VarDirective } from './utils/var.directive'; -import { NotificationComponent } from './notifications/notification/notification.component'; -import { NotificationsBoardComponent } from './notifications/notifications-board/notifications-board.component'; import { DragClickDirective } from './utils/drag-click.directive'; import { TruncatePipe } from './utils/truncate.pipe'; import { TruncatableComponent } from './truncatable/truncatable.component'; import { TruncatableService } from './truncatable/truncatable.service'; import { TruncatablePartComponent } from './truncatable/truncatable-part/truncatable-part.component'; import { MockAdminGuard } from './mocks/mock-admin-guard.service'; +import { DebounceDirective } from './utils/debounce.directive'; +import { ClickOutsideDirective } from './utils/click-outside.directive'; +import { EmphasizePipe } from './utils/emphasize.pipe'; +import { InputSuggestionsComponent } from './input-suggestions/input-suggestions.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -65,7 +67,8 @@ const PIPES = [ EnumKeysPipe, FileSizePipe, SafeUrlPipe, - TruncatePipe + TruncatePipe, + EmphasizePipe ]; const COMPONENTS = [ @@ -89,6 +92,7 @@ const COMPONENTS = [ ViewModeSwitchComponent, TruncatableComponent, TruncatablePartComponent, + InputSuggestionsComponent ]; const ENTRY_COMPONENTS = [ @@ -110,7 +114,9 @@ const PROVIDERS = [ const DIRECTIVES = [ VarDirective, - DragClickDirective + DragClickDirective, + DebounceDirective, + ClickOutsideDirective ]; @NgModule({ diff --git a/src/app/shared/utils/click-outside.directive.ts b/src/app/shared/utils/click-outside.directive.ts new file mode 100644 index 0000000000..2c759fe8fa --- /dev/null +++ b/src/app/shared/utils/click-outside.directive.ts @@ -0,0 +1,20 @@ +import {Directive, ElementRef, Output, EventEmitter, HostListener} from '@angular/core'; + +@Directive({ + selector: '[dsClickOutside]' +}) +export class ClickOutsideDirective { + constructor(private _elementRef: ElementRef) { + } + + @Output() + public dsClickOutside = new EventEmitter(); + + @HostListener('document:click', ['$event.target']) + public onClick(targetElement) { + const clickedInside = this._elementRef.nativeElement.contains(targetElement); + if (!clickedInside) { + this.dsClickOutside.emit(null); + } + } +} diff --git a/src/app/shared/utils/debounce.directive.ts b/src/app/shared/utils/debounce.directive.ts new file mode 100644 index 0000000000..d095bfcd24 --- /dev/null +++ b/src/app/shared/utils/debounce.directive.ts @@ -0,0 +1,43 @@ +import { Directive, Input, Output, EventEmitter, OnDestroy, OnInit } from '@angular/core'; +import { NgControl } from '@angular/forms'; +import 'rxjs/add/operator/debounceTime'; +import 'rxjs/add/operator/distinctUntilChanged'; +import 'rxjs/add/operator/takeUntil'; +import { Subject } from 'rxjs/Subject'; + +@Directive({ + selector: '[ngModel][dsDebounce]', +}) +export class DebounceDirective implements OnInit, OnDestroy { + @Output() + public onDebounce = new EventEmitter(); + + @Input('dsDebounce') + public debounceTime = 500; + + private isFirstChange = true; + private ngUnsubscribe: Subject = new Subject(); + + constructor(public model: NgControl) { + } + + ngOnInit() { + this.model.valueChanges + .takeUntil(this.ngUnsubscribe) + .debounceTime(this.debounceTime) + .distinctUntilChanged() + .subscribe((modelValue) => { + if (this.isFirstChange) { + this.isFirstChange = false; + } else { + this.onDebounce.emit(modelValue); + } + }); + } + + ngOnDestroy() { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); + } + +} \ No newline at end of file diff --git a/src/app/shared/utils/emphasize.pipe.ts b/src/app/shared/utils/emphasize.pipe.ts new file mode 100644 index 0000000000..506974c569 --- /dev/null +++ b/src/app/shared/utils/emphasize.pipe.ts @@ -0,0 +1,36 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ name: 'dsEmphasize' }) +export class EmphasizePipe implements PipeTransform { + specials = [ + // order matters for these + '-' + , '[' + , ']' + // order doesn't matter for any of these + , '/' + , '{' + , '}' + , '(' + , ')' + , '*' + , '+' + , '?' + , '.' + , '\\' + , '^' + , '$' + , '|' + ]; + regex = RegExp('[' + this.specials.join('\\') + ']', 'g'); + + transform(haystack, needle): any { + const escaped = this.escapeRegExp(needle); + const reg = new RegExp(escaped, 'gi'); + return haystack.replace(reg, '$&'); + } + + escapeRegExp(str) { + return str.replace(this.regex, '\\$&'); + } +}