Merge pull request #4976 from GabrielInTheWorld/improvingSuperSearch

Improves the global search to find IDs
This commit is contained in:
Emanuel Schütze 2019-09-05 14:17:27 +02:00 committed by GitHub
commit 20f1f982ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 146 additions and 56 deletions

View File

@ -192,16 +192,16 @@ export class SearchService {
* *
* @param query The search query * @param query The search query
* @param inCollectionStrings All connection strings which should be used for searching. * @param inCollectionStrings All connection strings which should be used for searching.
* @param sortingProperty Sorting by `id` or `title`.
* @param dedicatedId Optional parameter. Useful to look for a specific id in the given collectionStrings. * @param dedicatedId Optional parameter. Useful to look for a specific id in the given collectionStrings.
* @param searchOnlyById Optional parameter. Decides, whether all models should only be filtered by their id.
* *
* @returns All search results sorted by the model's title (via `getTitle()`). * @returns All search results sorted by the model's title (via `getTitle()`).
*/ */
public search( public search(
query: string, query: string,
inCollectionStrings: string[], inCollectionStrings: string[],
sortingProperty: 'title' | 'id' = 'title', dedicatedId?: number,
dedicatedId?: number searchOnlyById: boolean = false
): SearchResult[] { ): SearchResult[] {
query = query.toLowerCase(); query = query.toLowerCase();
return this.searchModels return this.searchModels
@ -211,20 +211,15 @@ export class SearchService {
.getAll(searchModel.collectionString) .getAll(searchModel.collectionString)
.map(x => x as (BaseViewModel & Searchable)) .map(x => x as (BaseViewModel & Searchable))
.filter(model => .filter(model =>
dedicatedId !searchOnlyById
? model.id === dedicatedId ? model.id === dedicatedId ||
: model model
.formatForSearch() .formatForSearch()
.searchValue.some(text => text && text.toLowerCase().indexOf(query) !== -1) .searchValue.some(text => text && text.toLowerCase().indexOf(query) !== -1)
: model.id === dedicatedId
) )
.sort((a, b) => { .sort((a, b) => this.languageCollator.compare(a.getTitle(), b.getTitle()));
switch (sortingProperty) {
case 'id':
return a.id - b.id;
case 'title':
return this.languageCollator.compare(a.getTitle(), b.getTitle());
}
});
return { return {
collectionString: searchModel.collectionString, collectionString: searchModel.collectionString,
verboseName: results.length === 1 ? searchModel.verboseNameSingular : searchModel.verboseNamePlural, verboseName: results.length === 1 ? searchModel.verboseNameSingular : searchModel.verboseNamePlural,

View File

@ -1,7 +1,6 @@
.filter-menu-content-wrapper { .filter-menu-content-wrapper {
overflow-y: scroll; overflow-y: scroll;
height: 100%; height: 100%;
div.indent { div.indent {
margin-left: 24px; margin-left: 24px;
} }

View File

@ -38,6 +38,7 @@
<!-- Search bar --> <!-- Search bar -->
<os-rounded-input <os-rounded-input
#searchField
[model]="searchFieldInput" [model]="searchFieldInput"
[size]="'small'" [size]="'small'"
[fullWidth]="false" [fullWidth]="false"

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, Output, ViewChild, ViewEncapsulation } from '@angular/core'; import { Component, EventEmitter, HostListener, Input, Output, ViewChild, ViewEncapsulation } from '@angular/core';
import { MatBottomSheet } from '@angular/material/bottom-sheet'; import { MatBottomSheet } from '@angular/material/bottom-sheet';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
@ -9,6 +9,7 @@ import { BaseSortListService } from 'app/core/ui-services/base-sort-list.service
import { ViewportService } from 'app/core/ui-services/viewport.service'; import { ViewportService } from 'app/core/ui-services/viewport.service';
import { BaseViewModel } from 'app/site/base/base-view-model'; import { BaseViewModel } from 'app/site/base/base-view-model';
import { FilterMenuComponent } from './filter-menu/filter-menu.component'; import { FilterMenuComponent } from './filter-menu/filter-menu.component';
import { RoundedInputComponent } from '../rounded-input/rounded-input.component';
import { SortBottomSheetComponent } from './sort-bottom-sheet/sort-bottom-sheet.component'; import { SortBottomSheetComponent } from './sort-bottom-sheet/sort-bottom-sheet.component';
/** /**
@ -32,6 +33,9 @@ import { SortBottomSheetComponent } from './sort-bottom-sheet/sort-bottom-sheet.
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class SortFilterBarComponent<V extends BaseViewModel> { export class SortFilterBarComponent<V extends BaseViewModel> {
@ViewChild('searchField', { static: true })
public searchField: RoundedInputComponent;
/** /**
* The currently active sorting service for the list view * The currently active sorting service for the list view
*/ */
@ -225,4 +229,12 @@ export class SortFilterBarComponent<V extends BaseViewModel> {
const itemProperty = option.property as string; const itemProperty = option.property as string;
return itemProperty.charAt(0).toUpperCase() + itemProperty.slice(1); return itemProperty.charAt(0).toUpperCase() + itemProperty.slice(1);
} }
@HostListener('document:keydown', ['$event']) public onKeyDown(event: KeyboardEvent): void {
if (event.ctrlKey && event.key === 'f') {
event.preventDefault();
event.stopPropagation();
this.searchField.focus();
}
}
} }

View File

@ -9,18 +9,23 @@
></os-rounded-input> ></os-rounded-input>
<button mat-icon-button [matMenuTriggerFor]="filterMenu"><mat-icon>filter_list</mat-icon></button> <button mat-icon-button [matMenuTriggerFor]="filterMenu"><mat-icon>filter_list</mat-icon></button>
<mat-menu #filterMenu="matMenu"> <mat-menu #filterMenu="matMenu">
<button mat-menu-item (click)="setSearchStringForID()">
<mat-icon>{{ !!searchStringForID ? 'checked' : '' }}</mat-icon>
ID
</button>
<mat-divider></mat-divider>
<button <button
mat-menu-item mat-menu-item
*ngFor="let model of registeredModels" *ngFor="let model of registeredModels"
(click)="setCollection(model.verboseNamePlural)" (click)="setCollection(model.verboseNamePlural)"
> >
<mat-icon>{{ model.verboseNamePlural === searchCollection ? 'checked' : '' }}</mat-icon> <mat-icon>{{ model.collectionString === specificCollectionString ? 'checked' : '' }}</mat-icon>
{{ model.verboseNamePlural | translate }} {{ model.verboseNamePlural | translate }}
</button> </button>
</mat-menu> </mat-menu>
</div> </div>
<h4 *ngIf="searchResultCount > 0" class="result-count"> <h4 *ngIf="searchResultCount > 0" class="result-count">
{{ searchResultCount }} {{ searchResultCount === 1 ? ( 'result' | translate ) : ( 'results' | translate ) }} {{ searchResultCount }} {{ searchResultCount === 1 ? ('result' | translate) : ('results' | translate) }}
</h4> </h4>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<div class="result-view" *ngIf="searchResults.length > 0"> <div class="result-view" *ngIf="searchResults.length > 0">
@ -37,7 +42,9 @@
<mat-basic-chip <mat-basic-chip
class="lightblue filter-count" class="lightblue filter-count"
disableRipple disableRipple
matTooltip="{{ result.models.length === 1 ? ( 'result' | translate ) : ( 'results' | translate ) }}" matTooltip="{{
result.models.length === 1 ? ('result' | translate) : ('results' | translate)
}}"
>{{ result.models.length }}</mat-basic-chip >{{ result.models.length }}</mat-basic-chip
> >
</mat-panel-title> </mat-panel-title>
@ -84,7 +91,9 @@
</mat-accordion> </mat-accordion>
<div class="no-results" *ngIf="!selectedModel && searchString.length > 0"> <div class="no-results" *ngIf="!selectedModel && searchString.length > 0">
<span translate>No search result found</span> <span translate>No search result found</span>
<span *ngIf="searchCollection">&nbsp;({{ 'with filter' | translate }} "{{ searchCollection | translate }}")</span>. <span *ngIf="searchCollection"
>&nbsp;({{ 'with filter' | translate }} "{{ searchCollection | translate }}")</span
>.
</div> </div>
</div> </div>
<mat-divider [vertical]="true"></mat-divider> <mat-divider [vertical]="true"></mat-divider>

View File

@ -36,8 +36,20 @@ export class SuperSearchComponent implements OnInit {
/** /**
* Holds the collection-string of the specific collection. * Holds the collection-string of the specific collection.
*
* Is set, if the user has entered a collection.
*/ */
private specificCollectionString: string = null; public specificCollectionString: string = null;
/**
* Holds the input text the user entered to search for a specific id.
*/
public searchStringForID: string = null;
/**
* The specific id the user searches for.
*/
private specificID: number = null;
/** /**
* The results for the given query. * The results for the given query.
@ -119,11 +131,11 @@ export class SuperSearchComponent implements OnInit {
this.collectionStrings = this.registeredModels.map(rm => rm.collectionString); this.collectionStrings = this.registeredModels.map(rm => rm.collectionString);
this.translatedCollectionStrings = this.searchService.getTranslatedCollectionStrings(); this.translatedCollectionStrings = this.searchService.getTranslatedCollectionStrings();
this.searchForm.valueChanges.pipe(debounceTime(250)).subscribe(value => { this.searchForm.valueChanges.pipe(debounceTime(250)).subscribe((value: string) => {
if (value.trim() === '') { if (value.trim() === '') {
this.clearResults(); this.clearResults();
} else { } else {
this.specificCollectionString = this.searchSpecificCollection(value.trim()); this.prepareForSearch(value.trim());
} }
this.search(); this.search();
}); });
@ -136,22 +148,11 @@ export class SuperSearchComponent implements OnInit {
*/ */
private search(): void { private search(): void {
if (this.searchString !== '') { if (this.searchString !== '') {
// Local variable to check, if the user searches for a specific id.
let dedicatedId: number;
const query = this.searchString;
// Looks, if the query matches variations of 'nr.' followed by at least one digit.
// If so, the user searches for a specific id in some collections.
// Everything not case-sensitive.
if (query.match(/n\w*r\.?\:?\s*\d+/gi)) {
// If so, this expression finds the number.
dedicatedId = +query.match(/\d+/g);
}
this.searchResults = this.searchService.search( this.searchResults = this.searchService.search(
query, this.searchString,
this.specificCollectionString ? [this.specificCollectionString] : this.collectionStrings, this.specificCollectionString ? [this.specificCollectionString] : this.collectionStrings,
'title', this.specificID,
dedicatedId !!this.searchStringForID
); );
this.selectFirstResult(); this.selectFirstResult();
} else { } else {
@ -162,33 +163,71 @@ export class SuperSearchComponent implements OnInit {
.reduce((acc, current) => acc + current, 0); .reduce((acc, current) => acc + current, 0);
} }
/**
* Function to check several things.
*
* First the query is splitted and the first part is tested
* for a specific collection.
*
* Second the next part is tested for a specific id.
* It's looking for the word `id` or any kind of `nr.`
* and a number.
*
* @param query The user's input, he searches for.
*/
private prepareForSearch(query: string): void {
// The query is splitted by the first whitespace or the first ':'.
const splittedQuery = query.split(/\s*(?::|\s+)\s*/);
this.specificCollectionString = this.searchSpecificCollection(splittedQuery[0]);
if (this.specificCollectionString) {
this.searchCollection = splittedQuery.shift();
}
this.searchStringForID = this.searchSpecificId(splittedQuery[0]) ? splittedQuery.shift() : null;
// This test, whether the query includes an number --> Then get this number.
if (/\b\d+\b/g.test(splittedQuery[0])) {
this.specificID = +query.match(/\d+/g);
}
// The rest will be joined to one string.
this.searchString = splittedQuery.join(' ');
}
/** /**
* This function test, if the query matches some of the `collectionStrings`. * This function test, if the query matches some of the `collectionStrings`.
* *
* That indicates, that the user looks for items in a specific collection. * That indicates, that the user looks for items in a specific collection.
* *
* @returns { { collection: string, query: string[] } | null } Either an object containing the found collection and the query * @returns { string | null } Either an object containing the found collection and the query
* or null, if there exists none. * or null, if there exists none.
*/ */
private searchSpecificCollection(query: string): string | null { private searchSpecificCollection(query: string): string | null {
// The query is splitted by the first whitespace or the first ':'. // The query is splitted by the first whitespace or the first ':'.
const splittedQuery = query.split(/\s*(?::|\s+)\s*/);
const nextCollection = this.translatedCollectionStrings.find(item => const nextCollection = this.translatedCollectionStrings.find(item =>
// The value of the item should match the query plus any further // The value of the item should match the query plus any further
// characters (useful for splitted words in the query). // characters (useful for splitted words in the query).
// This will look, if the user searches in a specific collection. // This will look, if the user searches in a specific collection.
// Flag 'i' tells, that cases are ignored. // Flag 'i' tells, that cases are ignored.
new RegExp(item.value, 'i').test(splittedQuery[0]) // new RegExp(item.value, 'i').test(splittedQuery[0])
new RegExp(item.value, 'i').test(query)
); );
if (!!nextCollection) { return !!nextCollection ? nextCollection.collection : null;
this.searchString = splittedQuery.slice(1).join(' ');
this.searchCollection = splittedQuery[0];
return nextCollection.collection;
} else {
this.searchString = query;
this.searchCollection = '';
return null;
} }
/**
* Function to see, whether a string matches the word `id` or any kind of `nr`.
*
* @param query The query, which is tested for the word `id` or `nr`.
*
* @returns {boolean} If the given string matches any kind of the test-string.
*/
private searchSpecificId(query: string = ''): boolean {
// Looks, if the query matches variations of 'nr.' or 'id'
// If so, the user searches for a specific id in some collections.
// Everything not case-sensitive.
return !!(query.match(/\bn\w*r\.?\:?\b/gi) || query.match(/\bid\.?\:?\b/gi));
} }
/** /**
@ -215,6 +254,19 @@ export class SuperSearchComponent implements OnInit {
private selectNextResult(up: boolean): void { private selectNextResult(up: boolean): void {
const tmp = this.searchResults.flatMap((result: SearchResult) => result.models); const tmp = this.searchResults.flatMap((result: SearchResult) => result.models);
this.changeModel(tmp[(tmp.indexOf(this.selectedModel) + (up ? -1 : 1)).modulo(tmp.length)]); this.changeModel(tmp[(tmp.indexOf(this.selectedModel) + (up ? -1 : 1)).modulo(tmp.length)]);
this.scrollToSelected();
}
/**
* Function to scroll with the current selected model, if the user uses the keyboard to navigate.
*/
private scrollToSelected(): void {
const selectedElement = document.getElementsByClassName('selected')[0];
selectedElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
} }
/** /**
@ -223,12 +275,27 @@ export class SuperSearchComponent implements OnInit {
* @param collectionName The `verboseName` of the selected collection. * @param collectionName The `verboseName` of the selected collection.
*/ */
public setCollection(collectionName: string): void { public setCollection(collectionName: string): void {
this.searchCollection = this.searchCollection === collectionName ? '' : collectionName; this.searchCollection =
if (this.searchCollection !== '') { this.searchCollection.toLowerCase() === collectionName.toLowerCase() ? '' : collectionName;
this.searchForm.setValue(this.searchCollection + ': ' + this.searchString); this.setSearch();
} else {
this.searchForm.setValue(this.searchString);
} }
/**
* This function sets the string for id or clears the variable, if already existing.
*/
public setSearchStringForID(): void {
this.searchStringForID = !!this.searchStringForID ? null : 'id';
this.setSearch();
}
/**
* This function puts the various strings together.
*/
private setSearch(): void {
this.searchForm.setValue(
[this.searchCollection, this.searchStringForID].map(value => (value ? value + ': ' : '')).join('') +
this.searchString
);
} }
/** /**
@ -274,6 +341,7 @@ export class SuperSearchComponent implements OnInit {
this.selectedModel = null; this.selectedModel = null;
this.searchCollection = ''; this.searchCollection = '';
this.searchString = ''; this.searchString = '';
this.searchStringForID = null;
this.saveQueryToStorage(null); this.saveQueryToStorage(null);
} }
@ -304,6 +372,10 @@ export class SuperSearchComponent implements OnInit {
* @param event KeyboardEvent to listen to keyboard-inputs. * @param event KeyboardEvent to listen to keyboard-inputs.
*/ */
@HostListener('document:keydown', ['$event']) public onKeyNavigation(event: KeyboardEvent): void { @HostListener('document:keydown', ['$event']) public onKeyNavigation(event: KeyboardEvent): void {
if (event.ctrlKey && event.key === 'f') {
event.preventDefault();
event.stopPropagation();
}
if (!!this.selectedModel) { if (!!this.selectedModel) {
if (event.key === 'Enter') { if (event.key === 'Enter') {
this.viewResult(this.selectedModel); this.viewResult(this.selectedModel);

View File

@ -114,7 +114,7 @@ export class ViewCategory extends BaseViewModel<Category> implements CategoryTit
} }
public getDetailStateURL(): string { public getDetailStateURL(): string {
return '/motions/category'; return `motions/category/${this.id}`;
} }
/** /**

View File

@ -315,6 +315,8 @@ export class SiteComponent extends BaseComponent implements OnInit {
*/ */
@HostListener('document:keydown', ['$event']) public onKeyNavigation(event: KeyboardEvent): void { @HostListener('document:keydown', ['$event']) public onKeyNavigation(event: KeyboardEvent): void {
if (event.altKey && event.shiftKey && event.code === 'KeyF') { if (event.altKey && event.shiftKey && event.code === 'KeyF') {
event.preventDefault();
event.stopPropagation();
this.overlayService.showSearch(); this.overlayService.showSearch();
} }
} }