53881: facet search box

This commit is contained in:
lotte
2018-07-02 15:27:13 +02:00
parent 26b82bed30
commit ac4a1b179b
13 changed files with 296 additions and 19 deletions

View File

@@ -1,5 +1,5 @@
<div>
<div class="filters">
<div class="filters py-2">
<a *ngFor="let value of selectedValues" class="d-block"
[routerLink]="[getSearchLink()]"
[queryParams]="getRemoveParams(value)" queryParamsHandling="merge">
@@ -28,11 +28,15 @@
| translate}}</a>
</div>
</div>
<form #form="ngForm" (ngSubmit)="onSubmit(form.value)" class="add-filter"
[action]="getCurrentUrl()">
<input type="text" [(ngModel)]="filter" [name]="filterConfig.paramName" class="form-control"
aria-label="New filter input"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate" [ngModelOptions]="{standalone: true}"/>
<input type="submit" class="d-none"/>
</form>
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
[action]="getCurrentUrl()"
[name]="filterConfig.paramName"
[(ngModel)]="filter"
(submitSuggestion)="onSubmit($event)"
(clickSuggestion)="clickFilter($event)"
(findSuggestions)="findSuggestions($event)"
[getDisplayValue]="getDisplayValue"
ngDefaultControl
></ds-input-suggestions>
</div>

View File

@@ -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;
}
}
@@ -16,3 +14,4 @@
cursor: pointer;
}
}

View File

@@ -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<string[]> = 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<PaginatedList<FacetValue>>) => {
return rd.payload.page.map((facet) => facet.value)
}
);
}
)
} else {
this.filterSearchResults = Observable.of([]);
}
}
getDisplayValue(value: string, searchValue: string) {
return new EmphasizePipe().transform(value, searchValue);
}
}

View File

@@ -3,6 +3,9 @@
:host {
border: 1px solid map-get($theme-colors, light);
>div {
position: relative;
}
.search-filter-wrapper {
overflow: hidden;
}

View File

@@ -175,13 +175,18 @@ export class SearchService implements OnDestroy {
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, facetConfigObs);
}
getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions): Observable<RemoteData<PaginatedList<FacetValue>>> {
getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions, filterQuery?: string): Observable<RemoteData<PaginatedList<FacetValue>>> {
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<ResponseParsingService> {

View File

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

View File

@@ -0,0 +1,21 @@
<form #form="ngForm" (ngSubmit)="submitSuggestion.emit(form.value)"
[action]="action" (keydown)="onKeydown($event)"
(keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="close()">
<input #inputField type="text" [(ngModel)]="ngModel" [name]="name"
class="form-control"
[dsDebounce]="debounceTime" (onDebounce)="findSuggestions.emit($event)"
[placeholder]="placeholder"
[ngModelOptions]="{standalone: true}" autocomplete="off"/>
<input type="submit" class="d-none"/>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}">
<ul class="list-unstyled">
<li *ngFor="let suggestionOption of suggestions">
<a href="#" class="d-block" class="dropdown-item" (click)="onClickSuggestion(suggestionOption)" #suggestion>
<span [innerHTML]="getDisplayValue(suggestionOption, inputField.value)"></span>
</a>
</li>
</ul>
</div>
</form>

View File

@@ -0,0 +1,7 @@
.autocomplete {
width: 100%;
.dropdown-item {
white-space: normal;
word-break: break-word;
}
}

View File

@@ -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<boolean>(false);
selectedIndex = -1;
@ViewChild('inputField') queryInput: ElementRef;
@ViewChildren('suggestion') resultViews: QueryList<ElementRef>;
@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;
}
}

View File

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

View File

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

View File

@@ -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<any>();
@Input('dsDebounce')
public debounceTime = 500;
private isFirstChange = true;
private ngUnsubscribe: Subject<void> = new Subject<void>();
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();
}
}

View File

@@ -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, '<em>$&</em>');
}
escapeRegExp(str) {
return str.replace(this.regex, '\\$&');
}
}