Add advanced search

This commit is contained in:
Patrick Gebhardt 2020-06-22 19:22:08 +02:00
parent ad180b00cc
commit f68a2599e9
15 changed files with 376 additions and 92 deletions

View File

@ -5338,6 +5338,11 @@
"integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==",
"dev": true
},
"hammerjs": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
"integrity": "sha1-BO93hiz/K7edMPdpIJWTAiK/YPE="
},
"handle-thing": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",

View File

@ -22,6 +22,7 @@
"@angular/platform-browser-dynamic": "~8.2.14",
"@angular/router": "~8.2.14",
"@ngx-translate/core": "^12.1.2",
"hammerjs": "^2.0.8",
"ngx-device-detector": "^1.4.5",
"rxjs": "~6.4.0",
"tslib": "^1.10.0",

View File

@ -28,6 +28,8 @@ import {
MatDialogModule,
MatDividerModule,
MatRadioModule,
MatSliderModule,
MatSlideToggleModule,
MatStepperModule,
MatTabsModule,
MatTooltipModule
@ -44,6 +46,7 @@ import {ShareButtonComponent} from './components/share-button/share-button.compo
import {ShareDialogComponent} from './dialogs/share-dialog/share-dialog.component';
import {TeamComponent} from './containers/team/team.component';
import {DeviceDetectorModule} from 'ngx-device-detector';
import {ToggleSliderComponent} from './components/toggle-slider/toggle-slider.component';
@NgModule({
@ -62,7 +65,8 @@ import {DeviceDetectorModule} from 'ngx-device-detector';
BookmarkListComponent,
ShareButtonComponent,
ShareDialogComponent,
TeamComponent
TeamComponent,
ToggleSliderComponent
],
imports: [
BrowserModule,
@ -89,7 +93,9 @@ import {DeviceDetectorModule} from 'ngx-device-detector';
MatTabsModule,
MatBadgeModule,
MatStepperModule,
MatRadioModule
MatRadioModule,
MatSlideToggleModule,
MatSliderModule
],
providers: [],
bootstrap: [AppComponent],

View File

@ -1,42 +1,106 @@
<mat-card class="search-container">
<span matCardTitle>Search</span>
<mat-vertical-stepper>
<mat-step>
<ng-template matStepLabel>When is your trip?</ng-template>
<div class="vertical-wrap">
<mat-form-field appearance="outline">
<mat-label>Start</mat-label>
<input (change)="checkDates()" [(ngModel)]="from" [min]="today" matInput required type="date">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>End</mat-label>
<input (change)="checkDates()" [(ngModel)]="to" [min]="from" matInput required type="date">
</mat-form-field>
</div>
</mat-step>
<mat-step>
<ng-template matStepLabel>What climate would you prefer?</ng-template>
<div *ngFor="let key of multiPresetsKeys" class="sub-group">
<span class="label">{{key|translate}}:</span><br>
<mat-radio-group [ngModel]="multiPresetSelection[key]" [value]="undefined">
<mat-radio-button
#btn
(click)="btn.checked = onMultiPresetSelect(preset)"
*ngFor="let preset of multiPresets.get(key)"
[value]="preset.preset_id"
>{{preset.tag_label|translate}}</mat-radio-button>
</mat-radio-group>
</div>
</mat-step>
<mat-step>
<ng-template matStepLabel>Whats most important to you?</ng-template>
<div class="vertical">
<mat-checkbox *ngFor="let preset of singlePresets"
[(ngModel)]="singlePresetSelection[preset.preset_id]">{{preset.tag_label|translate}}</mat-checkbox>
</div>
</mat-step>
</mat-vertical-stepper>
<button (click)="onSearch()" [disabled]="!from || !to" class="search-btn" color="primary" mat-flat-button>Search
<mat-tab-group #tabGroup [animationDuration]="'0'" [selectedIndex]="selectedTab">
<!-- Guided Search Tab -->
<mat-tab label="Guided">
<mat-vertical-stepper>
<mat-step>
<ng-template matStepLabel>When is your trip?</ng-template>
<div class="vertical-wrap">
<mat-form-field appearance="outline">
<mat-label>Start</mat-label>
<input (change)="checkDates()" [(ngModel)]="from" [min]="today" matInput required type="date">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>End</mat-label>
<input (change)="checkDates()" [(ngModel)]="to" [min]="from" matInput required type="date">
</mat-form-field>
</div>
</mat-step>
<mat-step>
<ng-template matStepLabel>Which climate would you prefer?</ng-template>
<div *ngFor="let key of multiPresetsKeys" class="sub-group">
<span class="label">{{key|translate}}:</span><br>
<mat-radio-group [ngModel]="multiPresetSelection[key]" [value]="undefined">
<mat-radio-button
#btn
(click)="btn.checked = onMultiPresetSelect(preset)"
*ngFor="let preset of multiPresets.get(key)"
[value]="preset.preset_id"
>{{preset.tag_label|translate}}</mat-radio-button>
</mat-radio-group>
</div>
</mat-step>
<mat-step>
<ng-template matStepLabel>What else is important to you?</ng-template>
<div class="vertical">
<mat-checkbox *ngFor="let preset of singlePresets"
[(ngModel)]="singlePresetSelection[preset.preset_id]">{{preset.tag_label|translate}}</mat-checkbox>
</div>
</mat-step>
</mat-vertical-stepper>
</mat-tab>
<!-- Advanced Search Tab -->
<mat-tab label="Advanced">
<!-- Date -->
<section class="group">
<span class="title">Date</span>
<div class=" content vertical-wrap">
<mat-form-field appearance="outline">
<mat-label>Start</mat-label>
<input (change)="checkDates()" [(ngModel)]="from" [min]="today" matInput required type="date">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>End</mat-label>
<input (change)="checkDates()" [(ngModel)]="to" [min]="from" matInput required type="date">
</mat-form-field>
</div>
</section>
<!-- Text Filter -->
<section class="group">
<span class="title">Text</span>
<div class="content vertical-wrap">
<mat-form-field class="text-input">
<mat-label>Text search</mat-label>
<input [(ngModel)]="textFilter" matInput>
<button (click)="textFilter = ''" *ngIf="textFilter.length" mat-icon-button matSuffix>
<mat-icon>clear</mat-icon>
</button>
</mat-form-field>
<div class="horizontal space">
<span>Search in description </span>
<mat-slide-toggle [(ngModel)]="fullText"></mat-slide-toggle>
</div>
</div>
</section>
<!-- Climate Params -->
<section class="group">
<span class="title">Climate</span>
<app-toggle-slider [(model)]="temperatureMeanMax" [label]="'Max Temp'" [max]="45" [min]="0"></app-toggle-slider>
<app-toggle-slider [(model)]="precipitation" [label]="'Precipitation'" [max]="500"
[min]="0"></app-toggle-slider>
</section>
<!-- Financial -->
<section class="group">
<span class="title">Fincancial</span>
<app-toggle-slider [(model)]="accommodation" [label]="'Accommodation'" [max]="60" [min]="0"></app-toggle-slider>
</section>
</mat-tab>
</mat-tab-group>
<button (click)="onSearch(tabGroup.selectedIndex === 1)" [disabled]="!from || !to" class="search-btn" color="primary"
mat-flat-button>Search
<mat-icon matSuffix>search</mat-icon>
</button>
</mat-card>

View File

@ -2,13 +2,6 @@
display: flex;
flex-direction: column;
> .group {
flex: 0 0 auto;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
> .search-btn {
margin-top: 1rem;
}
@ -34,6 +27,38 @@
}
}
.group {
display: flex;
flex-direction: column;
margin: 1rem 0;
> .title {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
> .content {
margin: 0 2.5rem;
.text-input {
min-width: 14rem;
}
}
}
.horizontal {
display: flex;
flex-direction: row;
align-items: center;
&.space {
> * {
margin-right: 1rem;
}
}
}
.vertical {
display: flex;
flex-direction: column;

View File

@ -14,6 +14,9 @@ import {SearchService} from '../../services/search.service';
})
export class SearchInputComponent implements OnInit {
selectedTab = 0;
presets: Preset[];
singlePresets: Preset[];
multiPresets: Map<string, Preset[]>;
@ -21,9 +24,18 @@ export class SearchInputComponent implements OnInit {
from: string;
to: string;
// Guided Search
singlePresetSelection = {};
multiPresetSelection = {};
// Advanced Search
textFilter = '';
fullText = false;
temperatureMeanMax: number;
precipitation: number;
accommodation: number;
readonly today = this.from = formatDate(new Date(), 'yyyy-MM-dd', 'en-GB');
constructor(private router: Router, private ps: PresetService, private ss: SearchService) {
@ -43,41 +55,13 @@ export class SearchInputComponent implements OnInit {
this.multiPresets = this.ps.multiPresets;
this.multiPresetsKeys = [...this.multiPresets.keys()];
const prevInput = this.ss.loadSearchInput();
if (prevInput) {
this.from = prevInput.from;
this.to = prevInput.to;
this.singlePresetSelection = prevInput.singlePresetSelection;
this.multiPresetSelection = prevInput.multiPresetSelection;
}
this.loadSearch();
}
async onSearch() {
const query: Query = {
from: new Date(this.from).getTime(),
to: new Date(this.to).getTime(),
};
for (const preset of this.singlePresets) {
if (this.singlePresetSelection[preset.preset_id]) {
query[preset.parameter] = preset.value;
}
}
for (const key of this.multiPresetsKeys) {
if (this.multiPresetSelection[key]) {
query[key] = this.presets.find(preset => preset.preset_id === this.multiPresetSelection[key]).value;
}
}
this.ss.saveSearchInput({
from: this.from,
to: this.to,
singlePresetSelection: this.singlePresetSelection,
multiPresetSelection: this.multiPresetSelection
});
async onSearch(isAdvanced: boolean) {
this.saveSearch(isAdvanced);
const query = isAdvanced ? this.getQueryFromAdvanced() : this.getQueryFromGuided();
await this.router.navigate(['/search'], {queryParams: {q: objToBase64(query)}});
}
@ -107,4 +91,76 @@ export class SearchInputComponent implements OnInit {
this.to = formatDate(newToDate, 'yyyy-MM-dd', 'en-GB');
}
}
private getQueryFromGuided(): Query {
const query: Query = {
from: new Date(this.from).getTime(),
to: new Date(this.to).getTime(),
};
for (const preset of this.singlePresets) {
if (this.singlePresetSelection[preset.preset_id]) {
query[preset.parameter] = preset.value;
}
}
for (const key of this.multiPresetsKeys) {
if (this.multiPresetSelection[key]) {
query[key] = this.presets.find(preset => preset.preset_id === this.multiPresetSelection[key]).value;
}
}
return query;
}
private getQueryFromAdvanced(): Query {
const query: Query = {
from: new Date(this.from).getTime(),
to: new Date(this.to).getTime(),
};
if (this.textFilter.length > 0) {
query.fulltext = this.fullText;
query.textfilter = this.textFilter;
}
query.temperature_mean_max = this.temperatureMeanMax ? [this.temperatureMeanMax, this.temperatureMeanMax] : undefined;
query.precipitation = this.precipitation ? [this.precipitation, this.precipitation] : undefined;
query.accommodation_costs = this.accommodation ? [this.accommodation, this.accommodation] : undefined;
return query;
}
private saveSearch(isAdvanced: boolean) {
this.ss.saveSearchInput({
wasAdvanced: isAdvanced,
from: this.from,
to: this.to,
singlePresetSelection: this.singlePresetSelection,
multiPresetSelection: this.multiPresetSelection,
fullText: this.fullText,
textFiler: this.textFilter,
tempMeanMax: this.temperatureMeanMax,
precipitation: this.precipitation,
accommodation: this.accommodation,
});
}
private loadSearch() {
const prevInput = this.ss.loadSearchInput();
if (prevInput) {
this.from = prevInput.from;
this.to = prevInput.to;
this.singlePresetSelection = prevInput.singlePresetSelection;
this.multiPresetSelection = prevInput.multiPresetSelection;
this.textFilter = prevInput.textFiler;
this.fullText = prevInput.fullText;
this.selectedTab = prevInput.wasAdvanced ? 1 : 0;
this.temperatureMeanMax = prevInput.tempMeanMax;
this.precipitation = prevInput.precipitation;
this.accommodation = prevInput.accommodation;
}
}
}

View File

@ -0,0 +1,11 @@
<span>{{label}}</span>
<mat-slide-toggle (change)="onSlideToggleChange($event)" [(ngModel)]="enabled"></mat-slide-toggle>
<mat-slider
(click)="enabled=true"
[(value)]="value"
[disabled]="!enabled"
[max]="max"
[min]="min"
[step]="step"
[thumbLabel]="true"
></mat-slider>

View File

@ -0,0 +1,22 @@
:host {
display: flex;
flex-direction: row;
align-items: center;
}
span {
width: 33%;
margin-right: 0.5rem;
text-overflow: ellipsis;
white-space: nowrap;
}
mat-slide-toggle {
flex: 0 1 auto;
margin-right: 0.5rem;
}
mat-slider {
flex: 1 1 auto;
}

View File

@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {ToggleSliderComponent} from './toggle-slider.component';
describe('ToggleSliderComponent', () => {
let component: ToggleSliderComponent;
let fixture: ComponentFixture<ToggleSliderComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ToggleSliderComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ToggleSliderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,42 @@
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import {MatSlideToggleChange} from '@angular/material';
@Component({
selector: 'app-toggle-slider',
templateUrl: './toggle-slider.component.html',
styleUrls: ['./toggle-slider.component.scss']
})
export class ToggleSliderComponent implements OnInit {
enabled = false;
rawValue: number;
@Output() modelChange: EventEmitter<number> = new EventEmitter<number>();
@Input() min = 0;
@Input() max = 100;
@Input() step = 1;
@Input() label: string;
get value(): number {
return this.rawValue;
}
set value(value: number) {
this.rawValue = value;
this.modelChange.emit(value);
}
@Input()
set model(value: number) {
this.rawValue = value;
this.enabled = value !== undefined;
}
ngOnInit() {
}
onSlideToggleChange(event: MatSlideToggleChange) {
if (event.checked === false) {
this.modelChange.emit(undefined);
}
}
}

View File

@ -9,6 +9,10 @@
</div>
</div>
<div *ngIf="results && results.length === 0" class="note">
<mat-icon>error</mat-icon>
<span>No match found!</span>
</div>
<div *ngIf="!results" class="spinner">
<mat-spinner></mat-spinner>
</div>

View File

@ -15,6 +15,21 @@
> app-result {
margin-bottom: 3rem;
}
}
.note {
flex: 1 1 auto;
align-self: center;
font-size: 1.2rem;
margin: 1.5rem;
display: flex;
flex-direction: row;
align-items: center;
mat-icon {
margin-right: 0.5rem;
}
}
.spinner {

View File

@ -3,10 +3,16 @@ import {Result} from '../interfaces/result.interface';
import {DataService} from './data.service';
export interface SearchInput {
wasAdvanced: boolean;
from: string;
to: string;
singlePresetSelection: object;
multiPresetSelection: object;
textFiler: string;
fullText: boolean;
tempMeanMax: number;
precipitation: number;
accommodation: number;
}
@Injectable({

View File

@ -1,8 +1,10 @@
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import {enableProdMode} from '@angular/core';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import {AppModule} from './app/app.module';
import {environment} from './environments/environment';
import 'hammerjs';
if (environment.production) {
enableProdMode();

View File

@ -55,20 +55,20 @@ $travopti-green: (
A400: #006400,
A700: #004d00,
contrast: (
50: $dark-primary-text,
100: $dark-primary-text,
200: $dark-primary-text,
300: $dark-primary-text,
400: $dark-primary-text,
500: $dark-primary-text,
50: $light-primary-text,
100: $light-primary-text,
200: $light-primary-text,
300: $light-primary-text,
400: $light-primary-text,
500: $light-primary-text,
600: $light-primary-text,
700: $light-primary-text,
800: $light-primary-text,
900: $light-primary-text,
A100: $dark-primary-text,
A200: $dark-primary-text,
A400: $dark-primary-text,
A700: $dark-primary-text,
A100: $light-primary-text,
A200: $light-primary-text,
A400: $light-primary-text,
A700: $light-primary-text,
)
);