mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 18:14:17 +00:00
53881: facet search box
This commit is contained in:
@@ -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>
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -3,6 +3,9 @@
|
||||
|
||||
:host {
|
||||
border: 1px solid map-get($theme-colors, light);
|
||||
>div {
|
||||
position: relative;
|
||||
}
|
||||
.search-filter-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
@@ -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> {
|
||||
|
@@ -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) => {
|
||||
|
@@ -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>
|
@@ -0,0 +1,7 @@
|
||||
.autocomplete {
|
||||
width: 100%;
|
||||
.dropdown-item {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
||||
}
|
@@ -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({
|
||||
|
20
src/app/shared/utils/click-outside.directive.ts
Normal file
20
src/app/shared/utils/click-outside.directive.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
43
src/app/shared/utils/debounce.directive.ts
Normal file
43
src/app/shared/utils/debounce.directive.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
36
src/app/shared/utils/emphasize.pipe.ts
Normal file
36
src/app/shared/utils/emphasize.pipe.ts
Normal 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, '\\$&');
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user