Initial commit: Aeroflot Flights Web Angular 12 application
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { SettingsResolver } from '@shared/services';
|
||||
import { FeatureFlagGuard } from './guards/feature-flag.guard'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'onlineboard',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'ru',
|
||||
redirectTo: 'onlineboard'
|
||||
},
|
||||
{
|
||||
path: 'en',
|
||||
redirectTo: 'onlineboard'
|
||||
},
|
||||
{
|
||||
path: 'es',
|
||||
redirectTo: 'onlineboard'
|
||||
},
|
||||
{
|
||||
path: 'fr',
|
||||
redirectTo: 'onlineboard'
|
||||
},
|
||||
{
|
||||
path: 'it',
|
||||
redirectTo: 'onlineboard'
|
||||
},
|
||||
{
|
||||
path: 'ja',
|
||||
redirectTo: 'onlineboard'
|
||||
},
|
||||
{
|
||||
path: 'ko',
|
||||
redirectTo: 'onlineboard'
|
||||
},
|
||||
{
|
||||
path: 'zh',
|
||||
redirectTo: 'onlineboard'
|
||||
},
|
||||
{
|
||||
path: 'de',
|
||||
redirectTo: 'onlineboard'
|
||||
},
|
||||
{
|
||||
path: 'onlineboard',
|
||||
loadChildren: () =>
|
||||
import('./features/online-board/online-board.module').then(
|
||||
(m) => m.OnlineBoardModule,
|
||||
),
|
||||
resolve: {
|
||||
[SettingsResolver.KEY]: SettingsResolver,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'schedule',
|
||||
loadChildren: () =>
|
||||
import('./features/schedule/schedule.module').then(
|
||||
(m) => m.ScheduleModule,
|
||||
),
|
||||
resolve: {
|
||||
[SettingsResolver.KEY]: SettingsResolver,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'flights-map',
|
||||
data: { featureFlag: 'flightsMap' } as const,
|
||||
canLoad: [FeatureFlagGuard],
|
||||
loadChildren: () =>
|
||||
import('./features/flights-map/flights-map.module').then(
|
||||
(m) => m.FlightsMapModule,
|
||||
),
|
||||
resolve: {
|
||||
[SettingsResolver.KEY]: SettingsResolver,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'error',
|
||||
loadChildren: () =>
|
||||
import('./modules/pages/error-pages/error-pages.module').then(
|
||||
(m) => m.ErrorPagesModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: 'error/404',
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
onSameUrlNavigation: 'reload',
|
||||
enableTracing: false,
|
||||
initialNavigation: 'enabled',
|
||||
}),
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AppRoutingModule {}
|
||||
@@ -0,0 +1,3 @@
|
||||
<router-outlet></router-outlet>
|
||||
<app-version></app-version>
|
||||
<chat-bot></chat-bot>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { UserLocationService } from '@shared/services/user-location/user-location.service';
|
||||
|
||||
@Component({
|
||||
selector: 'flights-root',
|
||||
templateUrl: './app.component.html',
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
constructor(private userLocation: UserLocationService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.userLocation.locate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { NgModule, ErrorHandler, LOCALE_ID } from '@angular/core';
|
||||
import {
|
||||
HttpClient,
|
||||
HttpClientModule,
|
||||
HTTP_INTERCEPTORS,
|
||||
} from '@angular/common/http';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import {
|
||||
TranslateModule,
|
||||
TranslateLoader,
|
||||
TranslateService,
|
||||
} from '@ngx-translate/core';
|
||||
|
||||
import { AppRoutingModule } from '@app/app-routing.module';
|
||||
import { AppComponent } from '@app/app.component';
|
||||
|
||||
import { ErrorHandlerProvider } from '@shared/providers';
|
||||
import {
|
||||
AppInsightService,
|
||||
appInsightsProvider,
|
||||
baseHrefProvider,
|
||||
LocalizationService,
|
||||
settingsProvider,
|
||||
windowProvider,
|
||||
} from '@shared/services';
|
||||
import { TranslateHttpLoaderFactory } from '@shared/factories';
|
||||
|
||||
import { SharedModule } from '@shared';
|
||||
import { FlightsModule } from '@modules/flights.module';
|
||||
|
||||
import localeRu from '@angular/common/locales/ru';
|
||||
import localeEn from '@angular/common/locales/en';
|
||||
import localeEs from '@angular/common/locales/es';
|
||||
import localeFr from '@angular/common/locales/fr';
|
||||
import localeIt from '@angular/common/locales/it';
|
||||
import localeJa from '@angular/common/locales/ja';
|
||||
import localeKo from '@angular/common/locales/ko';
|
||||
import localeZh from '@angular/common/locales/zh';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
|
||||
import { ScrollingModule } from '@angular/cdk/scrolling';
|
||||
|
||||
import { LayoutModule } from '@angular/cdk/layout';
|
||||
|
||||
import { AppInterceptor } from '@shared/interceptor/app.interceptor';
|
||||
|
||||
import { MomentModule } from 'ngx-moment';
|
||||
|
||||
registerLocaleData(localeRu);
|
||||
registerLocaleData(localeZh);
|
||||
registerLocaleData(localeEn);
|
||||
registerLocaleData(localeEs);
|
||||
registerLocaleData(localeFr);
|
||||
registerLocaleData(localeIt);
|
||||
registerLocaleData(localeJa);
|
||||
registerLocaleData(localeKo);
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
//BrowserModule.withServerTransition({ appId: 'serverApp' }),
|
||||
BrowserAnimationsModule,
|
||||
AppRoutingModule,
|
||||
FormsModule,
|
||||
SharedModule,
|
||||
FlightsModule,
|
||||
HttpClientModule,
|
||||
ScrollingModule,
|
||||
LayoutModule,
|
||||
MomentModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useFactory: TranslateHttpLoaderFactory,
|
||||
deps: [HttpClient],
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
baseHrefProvider,
|
||||
{
|
||||
provide: LOCALE_ID,
|
||||
deps: [LocalizationService],
|
||||
useFactory: (locale: LocalizationService) => locale.Language,
|
||||
},
|
||||
{
|
||||
provide: ErrorHandler,
|
||||
useClass: ErrorHandlerProvider,
|
||||
},
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: AppInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
appInsightsProvider,
|
||||
windowProvider,
|
||||
settingsProvider,
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {
|
||||
constructor(
|
||||
appInsights: AppInsightService,
|
||||
translate: TranslateService,
|
||||
locale: LocalizationService,
|
||||
) {
|
||||
appInsights.configure();
|
||||
locale.init(translate);
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { getDictionariesServiceMock } from '@modules/components/page-filters/services/dictionaries-service.mock';
|
||||
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
|
||||
import { Language } from '@shared/enumerators';
|
||||
import { sortStrings } from '@shared/helpers/sorterts';
|
||||
import { LocalizationService } from '@shared/services';
|
||||
|
||||
import { CitiesSearchService } from './cities-search.service';
|
||||
|
||||
describe('CitiesSearchService', () => {
|
||||
let service: CitiesSearchService;
|
||||
let dictService: DictionariesService;
|
||||
let localizationService: LocalizationService;
|
||||
|
||||
beforeEach(async () => {
|
||||
dictService = getDictionariesServiceMock();
|
||||
localizationService = {
|
||||
Language: Language.ru,
|
||||
} as any;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
CitiesSearchService,
|
||||
{ provide: LocalizationService, useValue: localizationService },
|
||||
{ provide: DictionariesService, useValue: dictService },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(CitiesSearchService);
|
||||
|
||||
await dictService.ready$;
|
||||
});
|
||||
|
||||
it('should sort results', () => {
|
||||
const input = 'М';
|
||||
const result = service.findCitiesByNameUnprocessed(input);
|
||||
const citiesNames = result.map((city) => city.name);
|
||||
const citiesNamesSorted = citiesNames.sort(sortStrings);
|
||||
|
||||
expect(citiesNamesSorted).toEqual(citiesNames);
|
||||
});
|
||||
|
||||
it('should return 10 cities that start with "М"', () => {
|
||||
const input = 'М';
|
||||
const result = service.findCitiesByNameUnprocessed(input);
|
||||
|
||||
expect(result.length).toBe(11);
|
||||
|
||||
for (const item of result) {
|
||||
expect(item.name[0]).toBe(input);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 3 cities. First should start with "Мо", other should include it', () => {
|
||||
const input = 'Мо';
|
||||
const inputLowerCase = input.toLowerCase();
|
||||
|
||||
const result = service.findCitiesByNameUnprocessed(input);
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
|
||||
const names = result.map((city) => city.name.toLowerCase());
|
||||
expect(names[0].indexOf(inputLowerCase)).toBe(0);
|
||||
expect(names[1].indexOf(inputLowerCase)).not.toBe(0);
|
||||
expect(names[1].includes(inputLowerCase)).toBeTruthy();
|
||||
expect(names[2].indexOf(inputLowerCase)).not.toBe(0);
|
||||
expect(names[2].includes(inputLowerCase)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return Moscow for "Мос" input', () => {
|
||||
const input = 'Мос';
|
||||
|
||||
const result = service.findCitiesByNameUnprocessed(input);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
|
||||
expect(result[0].name).toBe('Москва');
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
function assertResult(input: string) {
|
||||
const result = service.findCitiesByNameUnprocessed(input);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].name).toBe('Москва');
|
||||
}
|
||||
|
||||
assertResult('Мос');
|
||||
assertResult('МОС');
|
||||
assertResult('МоС');
|
||||
});
|
||||
|
||||
it('should add airports to found city', () => {
|
||||
const input = 'Мос';
|
||||
const result = service.findCitiesByName(input);
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0].name).toBe('Москва');
|
||||
expect(result[1].name).toBe('Внуково');
|
||||
expect(result[2].name).toBe('Шереметьево');
|
||||
});
|
||||
|
||||
it('should find city by city code', () => {
|
||||
const code = 'MOW';
|
||||
const result = service.findCitiesByCode(code);
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0].name).toBe('Москва');
|
||||
expect(result[1].name).toBe('Внуково');
|
||||
expect(result[2].name).toBe('Шереметьево');
|
||||
});
|
||||
|
||||
it('should find city by airport code', () => {
|
||||
const code = 'VKO';
|
||||
const result = service.findCitiesByAirportCode(code);
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0].name).toBe('Москва');
|
||||
expect(result[1].name).toBe('Внуково');
|
||||
expect(result[2].name).toBe('Шереметьево');
|
||||
});
|
||||
});
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
AirportModel,
|
||||
CityModel,
|
||||
} from '@modules/components/page-filters/models';
|
||||
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
|
||||
import { Language } from '@shared/enumerators';
|
||||
import { sortByName } from '@shared/helpers/sorterts';
|
||||
import { LocalizationService } from '@shared/services';
|
||||
|
||||
type AutocompleteItem = CityModel | AirportModel;
|
||||
|
||||
@Injectable()
|
||||
export class CitiesSearchService {
|
||||
private MAX_ITEMS_COUNT = 10;
|
||||
private CODE_LENGTH = 3;
|
||||
private lang: Language;
|
||||
|
||||
constructor(
|
||||
private localizationService: LocalizationService,
|
||||
private dictService: DictionariesService,
|
||||
) {
|
||||
this.lang = localizationService.Language;
|
||||
}
|
||||
|
||||
private static addAirportsToCities(
|
||||
cities: CityModel[],
|
||||
): AutocompleteItem[] {
|
||||
return cities.reduce((result, city) => {
|
||||
const airports = city.airports
|
||||
.filter((airport: AirportModel) => {
|
||||
return airport.code !== city.code;
|
||||
})
|
||||
.sort(sortByName);
|
||||
|
||||
return [...result, city, ...airports];
|
||||
}, [] as AutocompleteItem[]);
|
||||
}
|
||||
|
||||
private static removeDuplicates(cities: CityModel[]): CityModel[] {
|
||||
const citiesMap = new Map(cities.map((city) => [city.name, city]));
|
||||
|
||||
return Array.from(citiesMap, (mapItem) => mapItem[1]);
|
||||
}
|
||||
|
||||
private findAirportsByName(airportName: string) {
|
||||
return this.dictService.airportsAll
|
||||
.filter((airport) => {
|
||||
return (
|
||||
airport.name
|
||||
.toLowerCase()
|
||||
.indexOf(airportName.toLowerCase()) === 0
|
||||
);
|
||||
})
|
||||
.sort(sortByName);
|
||||
}
|
||||
|
||||
private findCitiesStartedWith(cityNamePart: string) {
|
||||
return this.dictService.citiesAll
|
||||
.filter((city) => {
|
||||
return (
|
||||
city.name
|
||||
.toLowerCase()
|
||||
.indexOf(cityNamePart.toLowerCase()) === 0
|
||||
);
|
||||
})
|
||||
.sort(sortByName);
|
||||
}
|
||||
|
||||
private findCitiesThatIncludes(citiNamePart: string) {
|
||||
return this.dictService.citiesAll
|
||||
.filter((city) => {
|
||||
return city.name
|
||||
.toLowerCase()
|
||||
.includes(citiNamePart.toLowerCase());
|
||||
})
|
||||
.sort(sortByName);
|
||||
}
|
||||
|
||||
private findCitiesByAirportName(airportName) {
|
||||
return this.findAirportsByName(airportName).map((airport) => {
|
||||
return this.dictService.getCityByCode(airport.city_code);
|
||||
});
|
||||
}
|
||||
|
||||
private findAirportByCode(code: string): AirportModel | null {
|
||||
if (code.length !== this.CODE_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.dictService.getAirportByCode(code);
|
||||
}
|
||||
|
||||
private processResult(cities: CityModel[]): AutocompleteItem[] {
|
||||
const prepared = cities.slice(0, this.MAX_ITEMS_COUNT);
|
||||
|
||||
return CitiesSearchService.addAirportsToCities(prepared);
|
||||
}
|
||||
|
||||
public findCitiesByNameUnprocessed(cityNamePart: string): CityModel[] {
|
||||
const startsWith = this.findCitiesStartedWith(cityNamePart);
|
||||
|
||||
if (startsWith.length > this.MAX_ITEMS_COUNT) {
|
||||
return startsWith;
|
||||
}
|
||||
|
||||
const includes = this.findCitiesThatIncludes(cityNamePart);
|
||||
const foundByCitiesNamePart = CitiesSearchService.removeDuplicates([
|
||||
...startsWith,
|
||||
...includes,
|
||||
]);
|
||||
|
||||
if (foundByCitiesNamePart.length > this.MAX_ITEMS_COUNT) {
|
||||
return foundByCitiesNamePart;
|
||||
}
|
||||
|
||||
const byAirports = this.findCitiesByAirportName(cityNamePart);
|
||||
const notStartsWith = [...includes, ...byAirports].sort(sortByName);
|
||||
return CitiesSearchService.removeDuplicates([
|
||||
...startsWith,
|
||||
...notStartsWith,
|
||||
]);
|
||||
}
|
||||
|
||||
public findCitiesByName(cityNamePart: string) {
|
||||
return this.processResult(
|
||||
this.findCitiesByNameUnprocessed(cityNamePart),
|
||||
);
|
||||
}
|
||||
|
||||
public findCitiesByAirportCode(code): AutocompleteItem[] {
|
||||
const airport = this.findAirportByCode(code);
|
||||
if (!airport) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.findCitiesByCode(airport.city_code);
|
||||
}
|
||||
|
||||
public findCitiesByCode(code: string): AutocompleteItem[] {
|
||||
if (code.length !== this.CODE_LENGTH) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const city = this.dictService.getCityByCode(code);
|
||||
|
||||
return city ? this.processResult([city]) : [];
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<div class="city-autocomplete__item" *ngIf="$any(item).countryName; else elseBlock">
|
||||
<span class="city">{{ item.name }},</span>
|
||||
<span class="country"> {{ $any(item).countryName }}</span>
|
||||
</div>
|
||||
<ng-template #elseBlock>
|
||||
<div class="city-autocomplete__item city-autocomplete__item--airport">
|
||||
<span class="airport">{{ item.name }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
@use "src/styles/framework" as *;
|
||||
|
||||
.city-autocomplete__item {
|
||||
white-space: nowrap;
|
||||
height: $button-height;
|
||||
border-bottom: 1.5px solid $border-input !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 0.429em;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--airport {
|
||||
padding-left: $space-xl;
|
||||
}
|
||||
|
||||
.city {
|
||||
white-space: nowrap;
|
||||
font-weight: $font-bold;
|
||||
}
|
||||
|
||||
.country {
|
||||
white-space: nowrap;
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
.airport {
|
||||
white-space: nowrap;
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import {
|
||||
AirportModel,
|
||||
CityModel,
|
||||
} from '@modules/components/page-filters/models';
|
||||
|
||||
@Component({
|
||||
selector: 'city-autocomplete-item',
|
||||
templateUrl: './city-autocomplete-item.component.html',
|
||||
styleUrls: ['./city-autocomplete-item.component.scss'],
|
||||
})
|
||||
export class CityAutocompleteItemComponent {
|
||||
@Input() item: CityModel | AirportModel;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<div class="city-autocomplete">
|
||||
<div class="city-autocomplete__labels-container">
|
||||
<label class="city-autocomplete__label">{{ label | translate }}</label>
|
||||
<label class="city-autocomplete__label" data-testid="city-code">{{ city?.code }}</label>
|
||||
</div>
|
||||
|
||||
<tooltip *ngIf="error">
|
||||
{{ error | translate }}
|
||||
</tooltip>
|
||||
|
||||
<div
|
||||
class="city-autocomplete__input"
|
||||
[ngClass]="{ 'city-autocomplete__input--has-value': city, 'city-autocomplete__input--has-error': error }"
|
||||
>
|
||||
<p-autoComplete
|
||||
#autocomplite
|
||||
[(ngModel)]="city"
|
||||
(ngModelChange)="onCityChange()"
|
||||
[autoHighlight]="true"
|
||||
[suggestions]="items"
|
||||
(completeMethod)="filterCity($event)"
|
||||
field="name"
|
||||
[size]="30"
|
||||
[minLength]="1"
|
||||
(onSelect)="selectLocation(city)"
|
||||
(onKeyUp)="inputChange()"
|
||||
placeholder="{{ placeholder | translate }}"
|
||||
data-testid="city-autocomplete-input"
|
||||
>
|
||||
<ng-template let-value pTemplate="item">
|
||||
<city-autocomplete-item [item]="value"></city-autocomplete-item>
|
||||
</ng-template>
|
||||
</p-autoComplete>
|
||||
|
||||
<!-- //todo: remove button-clear styles; create component instead -->
|
||||
<button pButton label=" " class="button-clear" (click)="clearInput()" data-testid="autocomplete-clear-input"></button>
|
||||
<button
|
||||
pButton
|
||||
label=" "
|
||||
class="city-autocomplete__search-button"
|
||||
[ngClass]="{ 'city-autocomplete__search-button--opened': openedPopup }"
|
||||
(click)="openPopUp()"
|
||||
data-testid="autocomplete-popup-button"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="city-autocomplete-popup-wrapper" *ngIf="openedPopup">
|
||||
<city-select [city]="city" (selectLocation)="selectLocation($event)" (locate)="onLocate()"></city-select>
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
@use "src/styles/framework" as *;
|
||||
|
||||
.city-autocomplete {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__labels-container {
|
||||
justify-content: space-between;
|
||||
margin-bottom: $space-m;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__label {
|
||||
@include font-overflow();
|
||||
@include font-small($gray);
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
@include control-border-shadow();
|
||||
}
|
||||
|
||||
&__search-button {
|
||||
width: $buttons-width !important;
|
||||
min-width: $buttons-width;
|
||||
border-radius: 0 $border-radius $border-radius 0 !important;
|
||||
border: none !important;
|
||||
border-left: 1px solid white !important;
|
||||
background-color: transparent !important;
|
||||
height: $standard-button-height - 2px;
|
||||
|
||||
&:hover {
|
||||
background-color: $white !important;
|
||||
border-left-color: $border-input !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__input--has-value {
|
||||
.button-clear {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.city-autocomplete__search-button {
|
||||
border-left: 1px solid $border-input !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__input--has-error {
|
||||
border-color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ElementRef, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { CitiesSearchService } from '@components/city-autocomplete/cities-search-service/cities-search.service';
|
||||
import { CityAutocompleteComponent } from '@components/city-autocomplete/city-autocomplete.component';
|
||||
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
|
||||
import { getDictionariesServiceMock } from '@modules/components/page-filters/services/dictionaries-service.mock';
|
||||
import { Language } from '@shared/enumerators';
|
||||
import { getMockPipe } from '@shared/pipes/pipe.mock';
|
||||
import { GeoService, LocalizationService } from '@shared/services';
|
||||
|
||||
describe('CityAutocompleteComponent', () => {
|
||||
let component: CityAutocompleteComponent;
|
||||
let fixture: ComponentFixture<CityAutocompleteComponent>;
|
||||
let citiesSearchService: CitiesSearchService;
|
||||
let dictService: DictionariesService;
|
||||
let elementRef: ElementRef;
|
||||
let geoService: GeoService;
|
||||
let localizationService: LocalizationService;
|
||||
|
||||
beforeEach(async () => {
|
||||
dictService = getDictionariesServiceMock();
|
||||
localizationService = {
|
||||
Language: Language.ru,
|
||||
} as any;
|
||||
citiesSearchService = new CitiesSearchService(
|
||||
localizationService,
|
||||
dictService,
|
||||
);
|
||||
elementRef = {
|
||||
nativeElement: {
|
||||
contains: jasmine.createSpy(),
|
||||
},
|
||||
};
|
||||
geoService = {
|
||||
getPosition: () => {
|
||||
return Promise.resolve({
|
||||
latitude: 1,
|
||||
longitude: 1,
|
||||
});
|
||||
},
|
||||
} as any;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [CityAutocompleteComponent, getMockPipe('translate')],
|
||||
providers: [
|
||||
{ provide: LocalizationService, useValue: localizationService },
|
||||
{ provide: DictionariesService, useValue: dictService },
|
||||
{ provide: CitiesSearchService, useValue: citiesSearchService },
|
||||
{ provide: ElementRef, useValue: elementRef },
|
||||
{ provide: GeoService, useValue: geoService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
});
|
||||
|
||||
await dictService.ready$;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CityAutocompleteComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.autocomplite = {
|
||||
hide: jasmine.createSpy(),
|
||||
} as any;
|
||||
});
|
||||
|
||||
it('should set items', () => {
|
||||
const event = { query: 'Мос' };
|
||||
component.filterCity(event);
|
||||
|
||||
expect(component.items.length).not.toBe(0);
|
||||
});
|
||||
|
||||
it("should set items even if keyboard layout isn't russian", () => {
|
||||
const event = { query: 'Vjc' }; // Vjc === Мос
|
||||
component.filterCity(event);
|
||||
|
||||
expect(component.items.length).not.toBe(0);
|
||||
});
|
||||
|
||||
it('should city if there is full match', () => {
|
||||
const event = { query: 'Москва' }; // Vjc === Мос
|
||||
jasmine.clock().install();
|
||||
|
||||
component.filterCity(event);
|
||||
jasmine.clock().tick(100);
|
||||
|
||||
expect(component.items).toBe(undefined);
|
||||
expect(component.city.name).toBe(event.query);
|
||||
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
forwardRef,
|
||||
HostListener,
|
||||
Input,
|
||||
Output,
|
||||
ViewChild,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { CitiesSearchService } from './cities-search-service/cities-search.service';
|
||||
import { Language } from '@shared/enumerators';
|
||||
import { LocalizationService } from '@shared/services';
|
||||
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { AutoComplete } from 'primeng/autocomplete';
|
||||
import { Destroyable } from '@shared/destroyable';
|
||||
import {
|
||||
AirportModel,
|
||||
CityModel,
|
||||
} from '@modules/components/page-filters/models';
|
||||
import { ruKeyboardLayout } from '@shared/helpers/keyboardLayoutConverter';
|
||||
|
||||
type AutocompleteItem = CityModel | AirportModel;
|
||||
|
||||
@Component({
|
||||
selector: 'city-autocomplete',
|
||||
templateUrl: 'city-autocomplete.component.html',
|
||||
styleUrls: ['./city-autocomplete.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => CityAutocompleteComponent), // replace name as appropriate
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class CityAutocompleteComponent
|
||||
extends Destroyable
|
||||
implements ControlValueAccessor
|
||||
{
|
||||
openedPopup = false;
|
||||
|
||||
items: AutocompleteItem[];
|
||||
regions: any[];
|
||||
countres: any[];
|
||||
airports: any[];
|
||||
city: CityModel | AirportModel;
|
||||
|
||||
@Input() label: string;
|
||||
@Input() placeholder: string;
|
||||
@Input() error: string;
|
||||
@Output() errorChange = new EventEmitter<string>();
|
||||
|
||||
@ViewChild('autocomplite') autocomplite: AutoComplete;
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
clickout(event) {
|
||||
if (!this.eRef.nativeElement.contains(event.target)) {
|
||||
this.focusOut();
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
private localizationService: LocalizationService,
|
||||
private dictService: DictionariesService,
|
||||
private eRef: ElementRef,
|
||||
private citiesSearch: CitiesSearchService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
writeValue(value: string) {
|
||||
this.city = this.dictService.getCityOrAirport(value);
|
||||
}
|
||||
|
||||
onTouched = () => {};
|
||||
onChange = (_: string) => {};
|
||||
|
||||
registerOnChange(fn: (value: string) => void) {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: () => void) {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
async onLocate() {
|
||||
const city = await this.dictService.locate();
|
||||
if (city) {
|
||||
this.selectLocation(city);
|
||||
}
|
||||
}
|
||||
|
||||
onCityChange() {
|
||||
if (!this.city || typeof this.city === 'string') {
|
||||
this.onChange(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
focusOut() {
|
||||
this.openedPopup = false;
|
||||
}
|
||||
|
||||
filterCity(event) {
|
||||
const text: string = event.query;
|
||||
//this.onChange(text);
|
||||
|
||||
if (!this.dictService.citiesAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.length === 3) {
|
||||
const byCityCode = this.citiesSearch.findCitiesByCode(text);
|
||||
if (byCityCode.length) {
|
||||
this.items = byCityCode;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const byName = this.citiesSearch.findCitiesByName(
|
||||
this.convertText(text),
|
||||
);
|
||||
|
||||
// If there is full match - select city and hide autocomplete menu
|
||||
if (
|
||||
byName.length &&
|
||||
text.toLowerCase() === byName[0].name.toLowerCase()
|
||||
) {
|
||||
setTimeout(() => {
|
||||
this.autocomplite.hide();
|
||||
|
||||
this.selectLocation(byName[0]);
|
||||
this.autocomplite.loading = false;
|
||||
}, 100);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (byName.length) {
|
||||
this.items = byName;
|
||||
return;
|
||||
}
|
||||
|
||||
const byCityCode = this.citiesSearch.findCitiesByCode(text);
|
||||
if (byCityCode.length) {
|
||||
this.items = byCityCode;
|
||||
return;
|
||||
}
|
||||
|
||||
this.items = this.citiesSearch.findCitiesByAirportCode(text);
|
||||
}
|
||||
|
||||
convertText(text: string) {
|
||||
return this.localizationService.Language === Language.ru
|
||||
? ruKeyboardLayout.fromEn(text)
|
||||
: text;
|
||||
}
|
||||
|
||||
selectLocation(value?: CityModel | AirportModel) {
|
||||
this.city = value;
|
||||
this.onChange(value?.code);
|
||||
this.openedPopup = false;
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetError();
|
||||
}
|
||||
|
||||
openPopUp() {
|
||||
this.openedPopup = !this.openedPopup;
|
||||
this.resetError();
|
||||
}
|
||||
|
||||
clearInput() {
|
||||
this.selectLocation(null);
|
||||
}
|
||||
|
||||
inputChange() {
|
||||
this.openedPopup = false;
|
||||
this.resetError();
|
||||
}
|
||||
|
||||
private resetError() {
|
||||
this.error = undefined;
|
||||
this.errorChange.emit(undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CitiesSearchService } from '@components/city-autocomplete/cities-search-service/cities-search.service';
|
||||
import { CityAutocompleteItemComponent } from '@components/city-autocomplete/city-autocomplete-item/city-autocomplete-item.component';
|
||||
import { CitySelectComponent } from '@components/city-autocomplete/city-select/city-select.component';
|
||||
import { AutoCompleteModule } from 'primeng/autocomplete';
|
||||
import { ToolkitModule } from '@toolkit/toolkit.module';
|
||||
import { ScrollPanelModule } from 'primeng/scrollpanel';
|
||||
import { CityAutocompleteComponent } from './city-autocomplete.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
CityAutocompleteComponent,
|
||||
CityAutocompleteItemComponent,
|
||||
CitySelectComponent,
|
||||
],
|
||||
providers: [CitiesSearchService],
|
||||
exports: [CityAutocompleteComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
AutoCompleteModule,
|
||||
ToolkitModule,
|
||||
ScrollPanelModule,
|
||||
],
|
||||
})
|
||||
export class CityAutocompleteModule {}
|
||||
@@ -0,0 +1,81 @@
|
||||
<div class="city-autocomplete-popup">
|
||||
<div class="tabs">
|
||||
<button
|
||||
pButton
|
||||
*ngFor="let item of dictService.regions"
|
||||
[ngClass]="{ active: item.id == currentRegion.id }"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
label="{{ item.name }}"
|
||||
class="tab-button"
|
||||
(click)="loadRegion(item)"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p-scrollPanel #sp class="content--scroll-panel" [style]="{ width: '100%', height: '350px' }">
|
||||
<div *ngFor="let item of currentRegion.countries" class="row" [ngClass]="{ 'country-start-row': item.name }">
|
||||
<div class="cell contry">
|
||||
<div>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="item.city1.airports.length == 1"
|
||||
class="cell city"
|
||||
[ngClass]="{ 'city-active': item.city1 == city }"
|
||||
attr.data-testid="city-name-cell-{{ item.city1.name }}"
|
||||
>
|
||||
<div class="city--item" (click)="onLocationSelect(item.city1)">
|
||||
{{ item.city1.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="item.city1.airports.length == 1" class="cell city" [ngClass]="{ 'city-active': item.city2 == city }">
|
||||
<div
|
||||
class="city--item"
|
||||
*ngIf="item.city2"
|
||||
(click)="onLocationSelect(item.city2)"
|
||||
attr.data-testid="city-name-cell-{{ item.city2.name }}"
|
||||
>
|
||||
{{ item.city2.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="item.city1.airports.length > 1" class="cell city" [ngClass]="{ 'city-active': item.city1 == city }">
|
||||
<div class="city--item" (click)="onLocationSelect(item.city1)" attr.data-testid="city-name-cell-{{ item.city1.name }}">
|
||||
{{ item.city1.name }}
|
||||
</div>
|
||||
|
||||
<div class="airports-column">
|
||||
<div
|
||||
*ngFor="let airport of item.city1.airports"
|
||||
pTooltip="{{ airport.name }}"
|
||||
tooltipPosition="top"
|
||||
class="city--item"
|
||||
(click)="onLocationSelect(airport)"
|
||||
attr.data-testid="airport-name-cell-{{ airport.name }}"
|
||||
>
|
||||
<!--[ngClass]="{'city-active':airport.code==city.code}"-->
|
||||
{{ airport.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</p-scrollPanel>
|
||||
</div>
|
||||
|
||||
<div *ngIf="featureFlags.FIND_MY_LOCATION_BUTTON" class="city-autocomplete-popup-footer">
|
||||
<div class="gps-contaner">
|
||||
<button
|
||||
class="gps-button color blue-light"
|
||||
pButton
|
||||
type="button"
|
||||
label="{{ 'BOARD.GPS-BUTTON' | translate }}"
|
||||
(click)="onLocate()"
|
||||
></button>
|
||||
<div class="gps-label-help">
|
||||
{{ 'BOARD.GPS-HELP' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
@use 'sass:math';
|
||||
@use "src/styles/framework" as *;
|
||||
|
||||
.city-autocomplete-popup {
|
||||
position: absolute;
|
||||
width: 630px;
|
||||
background: #ffffff;
|
||||
border-radius: $border-radius;
|
||||
opacity: 1;
|
||||
@include control-border-shadow();
|
||||
z-index: 10000;
|
||||
margin-top: $space-s;
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
.tab-button {
|
||||
height: auto;
|
||||
flex-grow: 1;
|
||||
border-radius: 0;
|
||||
background-color: $blue-extra-light;
|
||||
border: none;
|
||||
border-right: 1px solid $border;
|
||||
border-bottom: 1px solid $border;
|
||||
|
||||
&:hover {
|
||||
background-color: $blue-extra-light;
|
||||
border-right: 1px solid $border;
|
||||
border-bottom: 1px solid $border;
|
||||
|
||||
.p-button-label {
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: $white;
|
||||
border-bottom: 1px solid $white;
|
||||
|
||||
.p-button-label {
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-radius: $border-radius 0 0 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 $border-radius 0 0;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.p-button-label {
|
||||
color: $blue;
|
||||
font-weight: $font-bold;
|
||||
font-size: 14px;
|
||||
padding: 10px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.city-autocomplete-popup-footer {
|
||||
width: 100%;
|
||||
background: #f3f9ff;
|
||||
|
||||
.gps-contaner {
|
||||
width: 100%;
|
||||
padding: $space-xl;
|
||||
border-top: 1px solid $border;
|
||||
|
||||
.gps-button {
|
||||
height: $standard-button-height;
|
||||
width: 100%;
|
||||
background-color: $blue-light;
|
||||
|
||||
.p-button-label {
|
||||
font-size: $font-size-m;
|
||||
}
|
||||
}
|
||||
|
||||
.gps-label-help {
|
||||
margin-top: $space-l;
|
||||
@include font-small($gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cell {
|
||||
&.contry {
|
||||
align-self: flex-start;
|
||||
color: $text-color;
|
||||
font-weight: $font-bold;
|
||||
padding: 7.5px $space-m 7.5px 7.5px;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
&.city {
|
||||
flex: 1;
|
||||
padding-right: $space-m;
|
||||
@include font-overflow();
|
||||
|
||||
.city--item {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
@include font-overflow();
|
||||
display: inline-block;
|
||||
border-radius: $border-radius;
|
||||
padding: math.div($space-l, 2) math.div($space-l, 2);
|
||||
transition-duration: 0.1s;
|
||||
|
||||
&:hover {
|
||||
background-color: $blue-extra-light;
|
||||
}
|
||||
}
|
||||
|
||||
.airports-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $space-m;
|
||||
|
||||
.city--item {
|
||||
color: $light-gray;
|
||||
margin-right: $space-m;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
AirportModel,
|
||||
CityModel,
|
||||
RegionFlatModel,
|
||||
} from '@modules/components/page-filters/models';
|
||||
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
|
||||
import { FeatureFlagsService } from '@shared/services/feature-flags.service';
|
||||
import { ScrollPanel } from 'primeng/scrollpanel';
|
||||
|
||||
@Component({
|
||||
selector: 'city-select',
|
||||
templateUrl: './city-select.component.html',
|
||||
styleUrls: ['./city-select.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class CitySelectComponent implements OnInit {
|
||||
currentRegion: RegionFlatModel;
|
||||
@Input() city: CityModel | AirportModel;
|
||||
@Output() selectLocation: EventEmitter<CityModel | AirportModel> =
|
||||
new EventEmitter<CityModel | AirportModel>();
|
||||
@Output() locate = new EventEmitter();
|
||||
|
||||
@ViewChild('sp', { static: false }) scrollPanel: ScrollPanel;
|
||||
|
||||
constructor(
|
||||
public dictService: DictionariesService,
|
||||
public featureFlags: FeatureFlagsService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.currentRegion = this.dictService.regions.find(
|
||||
(x) => x.id == 500374,
|
||||
);
|
||||
}
|
||||
|
||||
loadRegion(region) {
|
||||
this.currentRegion = region;
|
||||
|
||||
this.scrollPanel.scrollTop(0);
|
||||
}
|
||||
|
||||
getRegionData() {
|
||||
if (this.currentRegion) {
|
||||
return this.currentRegion;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
onLocationSelect(model: CityModel | AirportModel) {
|
||||
this.selectLocation.emit(model);
|
||||
}
|
||||
|
||||
onLocate() {
|
||||
this.locate.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CityAutocompleteModule } from '@components/city-autocomplete/city-autocomplete.module';
|
||||
import { DayTabsComponent } from '@components/dates-selectors/day-tabs/day-tabs.component';
|
||||
import { WeekTabsComponent } from '@components/dates-selectors/week-tabs/week-tabs.component';
|
||||
import { PageModule } from '@components/page/page.module';
|
||||
import { SearchHistoryModule } from '@components/search-history/search-history.module';
|
||||
import { ToolkitModule } from '@toolkit/toolkit.module';
|
||||
import { SameUrlNavigationDetectorComponent } from './same-url-navigation-detector/same-url-navigation-detector.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
WeekTabsComponent,
|
||||
DayTabsComponent,
|
||||
SameUrlNavigationDetectorComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ToolkitModule,
|
||||
PageModule,
|
||||
CityAutocompleteModule,
|
||||
SearchHistoryModule,
|
||||
],
|
||||
exports: [
|
||||
PageModule,
|
||||
WeekTabsComponent,
|
||||
DayTabsComponent,
|
||||
SameUrlNavigationDetectorComponent,
|
||||
CityAutocompleteModule,
|
||||
SearchHistoryModule,
|
||||
],
|
||||
})
|
||||
export class ComponentsModule {}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
} from '@angular/core';
|
||||
import { IDateTab } from '@toolkit/date-tabs/date-tabs.component';
|
||||
import { areSame, isSameOrBefore } from '@utils/date';
|
||||
import { DatesTranslationService } from '@shared/services/dates-translation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'date-selector-base',
|
||||
template: '',
|
||||
})
|
||||
export abstract class DateSelectorBaseComponent<T = unknown>
|
||||
implements OnInit, OnChanges
|
||||
{
|
||||
@Input() caption!: string;
|
||||
@Input() selectedDate!: Date;
|
||||
@Input() dateFrom!: Date;
|
||||
@Input() dateTo!: Date;
|
||||
@Input() minDate: Date;
|
||||
@Input() maxDate: Date;
|
||||
@Input() tabsPerPage = 7;
|
||||
|
||||
@Output() tabClick = new EventEmitter<T>();
|
||||
|
||||
tabs: IDateTab<T>[] = [];
|
||||
|
||||
protected constructor(
|
||||
protected translationService: DatesTranslationService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.translationService.init().then(() => {
|
||||
this.tabs = this.generateTabs();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
this.updateTabs();
|
||||
}
|
||||
|
||||
public onTabClick(value: T) {
|
||||
this.tabClick.emit(value);
|
||||
}
|
||||
|
||||
protected isActive(date: Date) {
|
||||
if (!this.selectedDate || !date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return areSame(this.selectedDate, date);
|
||||
}
|
||||
|
||||
protected generateTabs(): IDateTab<T>[] {
|
||||
const tabs = [];
|
||||
let date = this.computeFirstDate();
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const tab = this.generateTab(date);
|
||||
|
||||
tabs.push(tab);
|
||||
date = this.computeNextDate(date);
|
||||
|
||||
// generate tabs until we reach this.dateTo and
|
||||
// can fulfill integer number of pages
|
||||
if (
|
||||
!isSameOrBefore(date, this.dateTo) &&
|
||||
tabs.length % this.tabsPerPage === 0
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
protected abstract updateTabs();
|
||||
protected abstract generateTab(date: Date): IDateTab<T>;
|
||||
protected abstract computeNextDate(date: Date): Date;
|
||||
protected abstract computeFirstDate(): Date;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<date-tabs
|
||||
[tabs]="tabs"
|
||||
[caption]="caption"
|
||||
[tabsPerPage]="tabsPerPage"
|
||||
(tabClick)="onTabClick($any($event))"
|
||||
></date-tabs>
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
|
||||
import { DateSelectorBaseComponent } from '../date-selector-base';
|
||||
import { IDateTab } from '@toolkit/date-tabs/date-tabs.component';
|
||||
import { DatesTranslationService } from '@shared/services/dates-translation.service';
|
||||
import * as moment from 'moment';
|
||||
import { FlightModel, getFirstLeg, Leg } from '../../../shared/models-legacy';
|
||||
|
||||
@Component({
|
||||
selector: 'day-tabs',
|
||||
templateUrl: './day-tabs.component.html',
|
||||
})
|
||||
export class DayTabsComponent extends DateSelectorBaseComponent<Date>
|
||||
implements OnInit, OnChanges {
|
||||
|
||||
@Input() disabledDates: Date[];
|
||||
@Input() flight: FlightModel | null;
|
||||
departure: Leg;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if ('flight' in changes && this.flight) {
|
||||
this.departure = getFirstLeg(this.flight);
|
||||
}
|
||||
|
||||
super.ngOnChanges(changes);
|
||||
}
|
||||
|
||||
constructor(protected translationService: DatesTranslationService) {
|
||||
super(translationService);
|
||||
}
|
||||
|
||||
protected computeNextDate(date: Date): Date {
|
||||
return moment(date).add(1, 'day').toDate();
|
||||
}
|
||||
|
||||
protected computeFirstDate(): Date {
|
||||
return this.dateFrom;
|
||||
}
|
||||
|
||||
protected updateTabs() {
|
||||
this.tabs = this.tabs.map((tab) => {
|
||||
var dayForTab = moment(tab.value).format('YYYYMMDD');
|
||||
|
||||
const isTabActive = this.isActive(tab.value);
|
||||
let isTabDisabled = !this.isEnabled(tab.value);
|
||||
|
||||
if (this.departure?.daysForTabs) {
|
||||
isTabDisabled = !this.departure.daysForTabs.includes(dayForTab) || isTabDisabled;
|
||||
}
|
||||
|
||||
if (this.disabledDates) {
|
||||
isTabDisabled ||= this.disabledDates.findIndex((x)=>moment(x).format('YYYYMMDD')==dayForTab) > -1;
|
||||
}
|
||||
|
||||
return {
|
||||
...tab,
|
||||
active: isTabActive,
|
||||
disabled: isTabDisabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected generateTab(day: Date): IDateTab<Date> {
|
||||
var dayForTab = moment(day).format('YYYYMMDD');
|
||||
|
||||
const isTabActive = this.isActive(day);
|
||||
let isTabDisabled = !this.isEnabled(day);
|
||||
|
||||
if (this.departure?.daysForTabs) {
|
||||
isTabDisabled = !this.departure.daysForTabs.includes(dayForTab) || isTabDisabled;
|
||||
}
|
||||
|
||||
if (this.disabledDates) {
|
||||
isTabDisabled ||= this.disabledDates.findIndex((x)=>moment(x).format('YYYYMMDD')==dayForTab) > -1;
|
||||
}
|
||||
|
||||
return {
|
||||
label: this.getTabLabel(day, isTabActive),
|
||||
mobileLabel: this.getTabMobileLabel(day),
|
||||
value: day,
|
||||
active: isTabActive,
|
||||
disabled: isTabDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
private getTabMobileLabel(day: Date) {
|
||||
return this.translationService.getFullDateString(day, true);
|
||||
}
|
||||
|
||||
private getTabLabel(day: Date, isTabActive: boolean) {
|
||||
const shortDateString = this.translationService.getShortDateString(day);
|
||||
const fullDateString = this.translationService.getFullDateString(day);
|
||||
|
||||
return isTabActive ? fullDateString.toLowerCase() : shortDateString;
|
||||
}
|
||||
|
||||
private isEnabled(day: Date) {
|
||||
return moment(day).isBetween(this.minDate, this.maxDate, 'day', '[]');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<date-tabs
|
||||
[tabs]="tabs"
|
||||
[caption]="caption"
|
||||
[tabsPerPage]="tabsPerPage"
|
||||
(tabClick)="onTabClick($any($event))"
|
||||
></date-tabs>
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Component, OnChanges, OnInit } from '@angular/core';
|
||||
import { getSunday, getStartOfTheWeek } from '@utils/date';
|
||||
import { DateSelectorBaseComponent } from '../date-selector-base';
|
||||
import { IDateTab } from '@toolkit/date-tabs/date-tabs.component';
|
||||
import { DatesTranslationService } from '@shared/services/dates-translation.service';
|
||||
import * as moment from 'moment';
|
||||
|
||||
export type IWeekTabValue = {
|
||||
dateFrom: Date;
|
||||
dateTo: Date;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'week-tabs',
|
||||
templateUrl: './week-tabs.component.html',
|
||||
})
|
||||
export class WeekTabsComponent
|
||||
extends DateSelectorBaseComponent<IWeekTabValue>
|
||||
implements OnChanges, OnInit {
|
||||
|
||||
ngOnInit() {
|
||||
super.ngOnInit();
|
||||
}
|
||||
|
||||
constructor(protected translationService: DatesTranslationService) {
|
||||
super(translationService);
|
||||
}
|
||||
|
||||
protected updateTabs() {
|
||||
this.tabs = this.tabs.map((tab) => {
|
||||
const sunday = tab.value.dateTo;
|
||||
const monday = tab.value.dateFrom;
|
||||
const isTabActive = this.isActive(monday);
|
||||
const isTabDisabled = !this.isEnabled(monday, sunday);
|
||||
|
||||
return {
|
||||
...tab,
|
||||
active: isTabActive,
|
||||
disabled: isTabDisabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
protected computeNextDate(date: Date): Date {
|
||||
return moment(date).add(1, 'week').toDate();
|
||||
}
|
||||
|
||||
protected computeFirstDate(): Date {
|
||||
return getStartOfTheWeek(this.dateFrom);
|
||||
}
|
||||
|
||||
protected generateTab(monday: Date): IDateTab<IWeekTabValue> {
|
||||
const isTabActive = this.isActive(monday);
|
||||
const sunday = getSunday(monday);
|
||||
const isTabDisabled = !this.isEnabled(monday, sunday);
|
||||
|
||||
return {
|
||||
label: this.getTabLabel(monday, sunday),
|
||||
mobileLabel: this.getTabMobileLabel(monday, sunday),
|
||||
value: {
|
||||
dateFrom: monday,
|
||||
dateTo: sunday,
|
||||
},
|
||||
active: isTabActive,
|
||||
disabled: isTabDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
private getTabLabel(monday: Date, sunday: Date) {
|
||||
const mondayShortDateString =
|
||||
this.translationService.getShortDateString(monday);
|
||||
|
||||
const sundayShortDateString =
|
||||
this.translationService.getShortDateString(sunday);
|
||||
|
||||
return `${mondayShortDateString} - ${sundayShortDateString}`;
|
||||
}
|
||||
|
||||
private getTabMobileLabel(monday: Date, sunday: Date) {
|
||||
const mondayFullDateString =
|
||||
this.translationService.getFullDateString(monday);
|
||||
|
||||
const sundayFullDateString =
|
||||
this.translationService.getFullDateString(sunday);
|
||||
|
||||
return `${mondayFullDateString} - ${sundayFullDateString}`;
|
||||
}
|
||||
|
||||
private isEnabled(monday: Date, sunday: Date) {
|
||||
return (
|
||||
moment(monday).isSameOrAfter(this.minDate) &&
|
||||
moment(sunday).isSameOrBefore(this.maxDate)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
|
||||
|
||||
@Component({
|
||||
selector: 'meta-base',
|
||||
template: '',
|
||||
})
|
||||
export abstract class MetaBaseComponent implements OnInit {
|
||||
title = '';
|
||||
description = '';
|
||||
|
||||
protected paramsName = 'params';
|
||||
|
||||
protected constructor(
|
||||
protected route: ActivatedRoute,
|
||||
protected dictionaries: DictionariesService,
|
||||
) {}
|
||||
|
||||
protected abstract translateTitle(params: string): string;
|
||||
protected abstract translateDescription(params: string): string;
|
||||
|
||||
protected getCity(cityCode: string) {
|
||||
return this.dictionaries.getCityOrAirport(cityCode)?.name || '';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.params.subscribe({
|
||||
next: (routerParams) => {
|
||||
const params = routerParams[this.paramsName];
|
||||
|
||||
this.dictionaries.ready$.then(() => {
|
||||
this.title = this.translateTitle(params);
|
||||
this.description = this.translateDescription(params);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<p-breadcrumb [model]="items"></p-breadcrumb>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { PageBreadcrumbsComponent } from '@components/page/breadcrumds/page-breadcrumbs.component';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { IRouteData } from '@typings/common/route';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
describe('PageBreadcrumbsComponent', () => {
|
||||
let fixture: ComponentFixture<PageBreadcrumbsComponent>;
|
||||
let component: PageBreadcrumbsComponent;
|
||||
|
||||
const data: IRouteData = {};
|
||||
|
||||
const instantMock = jasmine.createSpy('instant');
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [PageBreadcrumbsComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: instantMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
data: new BehaviorSubject(data),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(PageBreadcrumbsComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
data.breadcrumbs = undefined;
|
||||
instantMock.calls.reset();
|
||||
});
|
||||
|
||||
it('should contain only default breadcrumb item', () => {
|
||||
component.ngOnInit();
|
||||
expect(component.items.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should contain default breadcrumb item and items from data.breadcrumbs', () => {
|
||||
data.breadcrumbs = [
|
||||
{
|
||||
label: 'breadcrumb 1',
|
||||
url: 'url1',
|
||||
},
|
||||
{
|
||||
label: 'breadcrumb 2',
|
||||
url: 'url2',
|
||||
},
|
||||
];
|
||||
component.ngOnInit();
|
||||
expect(component.items.length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { IRouteData } from '@typings/common/route';
|
||||
import { MenuItem } from 'primeng/api';
|
||||
|
||||
export type IBreadCrumbItem = Required<Pick<MenuItem, 'label' | 'url'>>;
|
||||
const defaultMenuItem: IBreadCrumbItem = {
|
||||
label: 'SHARED.MAIN',
|
||||
url: 'https://www.aeroflot.ru',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'flights-page-breadcrumbs',
|
||||
templateUrl: './page-breadcrumbs.component.html',
|
||||
})
|
||||
export class PageBreadcrumbsComponent implements OnInit {
|
||||
items: IBreadCrumbItem[] = [];
|
||||
|
||||
constructor(
|
||||
private translation: TranslateService,
|
||||
private route: ActivatedRoute,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.route.data.subscribe((data: IRouteData) => {
|
||||
let items = [defaultMenuItem];
|
||||
if (data.breadcrumbs) {
|
||||
items = items.concat(data.breadcrumbs);
|
||||
}
|
||||
|
||||
this.items = this.translate(items);
|
||||
});
|
||||
}
|
||||
|
||||
private translate(items: IBreadCrumbItem[]) {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
label: this.translation.instant(item.label) || item.label,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<button class="feedback-button" (click)="showForm()">
|
||||
{{ 'SHARED.FEEDBACK' | translate }}
|
||||
</button>
|
||||
<feedback-form *ngIf="formVisible" (clickOutside)="hideForm()"></feedback-form>
|
||||
@@ -0,0 +1,21 @@
|
||||
@use 'src/styles/framework' as *;
|
||||
|
||||
.feedback-button {
|
||||
box-sizing: border-box;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 3px 10px;
|
||||
height: 25px;
|
||||
min-height: 25px;
|
||||
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
background-color: $orange--hover;
|
||||
color: $white;
|
||||
|
||||
font-size: $font-size-s;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'feedback-button',
|
||||
templateUrl: './feedback-button.component.html',
|
||||
styleUrls: ['./feedback-button.component.scss'],
|
||||
})
|
||||
export class FeedbackButtonComponent {
|
||||
formVisible = false;
|
||||
|
||||
showForm() {
|
||||
this.formVisible = true;
|
||||
}
|
||||
|
||||
hideForm() {
|
||||
this.formVisible = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<div class="feedback-form" (click)="handleWrapperClick()">
|
||||
<button class="feedback-form__close">
|
||||
<!-- inlined from assets/img/close.svg to change fill color -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">
|
||||
<path
|
||||
id="close"
|
||||
d="M19,6.41,17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z"
|
||||
transform="translate(-5 -5)"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<iframe
|
||||
class="feedback-form__iframe"
|
||||
src="https://forms.office.com/Pages/ResponsePage.aspx?id=DQSIkWdsW0yxEjajBLZtrQAAAAAAAAAAAAN__pz85X5UQ1dYVjA0RklSVlYwT0czWlhNSTZXWDA2Uy4u&embed=true"
|
||||
style="border: none; max-width: 100%; max-height: 100vh"
|
||||
allowfullscreen
|
||||
webkitallowfullscreen
|
||||
mozallowfullscreen
|
||||
msallowfullscreen
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
@use "src/styles/framework" as *;
|
||||
|
||||
.feedback-form {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
|
||||
z-index: 1009;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
padding: 24px;
|
||||
|
||||
background-color: $dark-blue-opacity;
|
||||
|
||||
@include mobile() {
|
||||
padding: $space-m;
|
||||
}
|
||||
|
||||
&__iframe {
|
||||
width: 640px;
|
||||
height: 480px;
|
||||
|
||||
@include mobile() {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__close {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
|
||||
display: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
background-color: transparent;
|
||||
|
||||
border: none;
|
||||
color: white;
|
||||
|
||||
@include mobile() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'feedback-form',
|
||||
templateUrl: './feedback-form.component.html',
|
||||
styleUrls: ['./feedback-form.component.scss'],
|
||||
})
|
||||
export class FeedbackFormComponent {
|
||||
@Output() clickOutside: EventEmitter<undefined> =
|
||||
new EventEmitter<undefined>();
|
||||
|
||||
handleWrapperClick() {
|
||||
this.clickOutside.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<div class="page-layout__row page-layout__header">
|
||||
<aside class="page-layout__column-left page-layout__header-left">
|
||||
<ng-content select="[header-left]"></ng-content>
|
||||
</aside>
|
||||
<div class="page-layout__column-right page-layout__header-right">
|
||||
<div [ngClass]="pageLayoutTitleClasses">
|
||||
<flights-page-breadcrumbs></flights-page-breadcrumbs>
|
||||
<ng-content select="[title]"></ng-content>
|
||||
</div>
|
||||
<feedback-button
|
||||
*ngIf="featureFlags.FEEDBACK_BUTTON_AVAILABLE"
|
||||
class="page-layout__feedback-button"
|
||||
></feedback-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-layout__row page-layout__content">
|
||||
<aside class="page-layout__column-left">
|
||||
<ng-content select="[content-left]"></ng-content>
|
||||
</aside>
|
||||
<main class="page-layout__column-right">
|
||||
<div class="page-layout__sticky-content">
|
||||
<ng-content select="[sticky-content]"></ng-content>
|
||||
</div>
|
||||
<scroll-up-button *ngIf="withScrollUp"></scroll-up-button>
|
||||
<ng-content></ng-content>
|
||||
</main>
|
||||
</div>
|
||||
<div *ngIf="withScrollOverlay" class="page-layout__scroll-overlay"></div>
|
||||
@@ -0,0 +1,164 @@
|
||||
@use "src/styles/framework" as *;
|
||||
@use "src/styles/positioning";
|
||||
|
||||
.page-layout {
|
||||
&__row {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: flex-start;
|
||||
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: $site-width;
|
||||
|
||||
@include print {
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
@include smTablet {
|
||||
flex-flow: column wrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__column-left {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 1001;
|
||||
|
||||
flex-shrink: 0;
|
||||
margin-left: 0;
|
||||
margin-right: 1.5%;
|
||||
width: $left-aside-width;
|
||||
|
||||
@media (max-width: $media-breakpoint-desktop) {
|
||||
width: $left-aside-width-desktop;
|
||||
}
|
||||
|
||||
@media (max-width: $media-breakpoint-tablet) {
|
||||
position: relative;
|
||||
top: 0;
|
||||
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__column-right {
|
||||
position: relative;
|
||||
width: calc(100% - #{$left-aside-width} - #{$column-spacing});
|
||||
|
||||
@include print {
|
||||
width: 100% !important;
|
||||
margin-top: 20px !important;
|
||||
}
|
||||
|
||||
@media (max-width: $media-breakpoint-desktop) {
|
||||
width: calc(100% - #{$left-aside-width-desktop} - 1.5%);
|
||||
}
|
||||
|
||||
@media (max-width: $media-breakpoint-tablet) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__header-right {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@include smTablet {
|
||||
flex-direction: column;
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__header-left {
|
||||
margin-top: auto;
|
||||
|
||||
@include smTablet {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
width: calc(100% - 120px);
|
||||
}
|
||||
|
||||
&__title--fullwidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
position: relative;
|
||||
z-index: 1001;
|
||||
|
||||
padding-top: $space-top-site;
|
||||
margin-bottom: $space-xl;
|
||||
|
||||
@include mobile {
|
||||
margin-bottom: $space-m;
|
||||
}
|
||||
}
|
||||
|
||||
&__content &__column-left{
|
||||
@include smTablet {
|
||||
margin-bottom: $space-xl;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
margin-bottom: $space-m;
|
||||
}
|
||||
}
|
||||
|
||||
&__content &__column-left:empty {
|
||||
@include smTablet {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__feedback-button {
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
|
||||
@include smTablet {
|
||||
margin-bottom: $space-m;
|
||||
}
|
||||
|
||||
@include print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__scroll-overlay {
|
||||
@include mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
position: fixed;
|
||||
z-index: positioning.$sticky-elements-z-index - 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: calc(100% - 20px); // set width less than viewport to prevent scroll clipping on windows
|
||||
height: positioning.$sticky-threshold + 5px; // increase height to prevent content revealing when sticky element corners rounded
|
||||
|
||||
background-color: $blue-dark !important;
|
||||
background-image: url('~src/assets/img/background.jpg');
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100vw;
|
||||
}
|
||||
|
||||
&__sticky-content {
|
||||
@include gt-mobile {
|
||||
position: sticky;
|
||||
z-index: positioning.$sticky-elements-z-index;
|
||||
top: positioning.$sticky-threshold;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ChangeDetectorRef } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { PageBreadcrumbsComponent } from '@components/page/breadcrumds/page-breadcrumbs.component';
|
||||
import { FeedbackButtonComponent } from '@components/page/feedback/button/feedback-button.component';
|
||||
import { FeedbackFormComponent } from '@components/page/feedback/form/feedback-form.component';
|
||||
import { PageLayoutComponent } from '@components/page/layout/page-layout.component';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { getMockPipe } from '@shared/pipes/pipe.mock';
|
||||
import { FeatureFlagsService } from '@shared/services/feature-flags.service';
|
||||
import { TitleComponent } from '@toolkit/title/title.component';
|
||||
import { BreadcrumbModule } from 'primeng/breadcrumb';
|
||||
import { TooltipModule } from 'primeng/tooltip';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
describe('PageLayoutComponent', () => {
|
||||
let component: PageLayoutComponent;
|
||||
let fixture: ComponentFixture<PageLayoutComponent>;
|
||||
let featureFlags: FeatureFlagsService;
|
||||
let hostElement: HTMLElement;
|
||||
|
||||
const instantMock = jasmine.createSpy('instant');
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
PageLayoutComponent,
|
||||
PageBreadcrumbsComponent,
|
||||
FeedbackButtonComponent,
|
||||
FeedbackFormComponent,
|
||||
TitleComponent,
|
||||
getMockPipe('translate'),
|
||||
],
|
||||
providers: [
|
||||
FeatureFlagsService,
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: instantMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
data: new BehaviorSubject({}),
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [BreadcrumbModule, TooltipModule],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(PageLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
hostElement = fixture.nativeElement;
|
||||
featureFlags = TestBed.inject(FeatureFlagsService);
|
||||
|
||||
instantMock.calls.reset();
|
||||
});
|
||||
|
||||
it('should return object with page-layout__title class if feedback feature is enabled', () => {
|
||||
featureFlags.FEEDBACK_BUTTON_AVAILABLE = true;
|
||||
|
||||
expect(component.pageLayoutTitleClasses).toEqual({
|
||||
'page-layout__title': true,
|
||||
'page-layout__title--fullwidth': false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return object with all classes if feedback feature is disabled', () => {
|
||||
expect(component.pageLayoutTitleClasses).toEqual({
|
||||
'page-layout__title': true,
|
||||
'page-layout__title--fullwidth': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render feedback button component', () => {
|
||||
const button = hostElement.querySelector('feedback-button');
|
||||
expect(button).toBe(null);
|
||||
});
|
||||
|
||||
it('should render feedback button component', () => {
|
||||
featureFlags.FEEDBACK_BUTTON_AVAILABLE = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = hostElement.querySelector('feedback-button');
|
||||
expect(button).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should not render scroll overlay if withScrollOverlay is false', () => {
|
||||
fixture.detectChanges();
|
||||
let overlay = hostElement.querySelector('.page-layout__scroll-overlay');
|
||||
expect(overlay).toBeDefined();
|
||||
|
||||
component.withScrollOverlay = false;
|
||||
const changeDetectorRef =
|
||||
fixture.debugElement.injector.get<ChangeDetectorRef>(
|
||||
ChangeDetectorRef,
|
||||
);
|
||||
changeDetectorRef.detectChanges();
|
||||
|
||||
overlay = hostElement.querySelector('.page-layout__scroll-overlay');
|
||||
expect(overlay).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { FeatureFlagsService } from '@shared/services/feature-flags.service';
|
||||
|
||||
@Component({
|
||||
selector: 'page-layout',
|
||||
templateUrl: './page-layout.component.html',
|
||||
styleUrls: ['./page-layout.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PageLayoutComponent {
|
||||
@Input() withScrollOverlay = true;
|
||||
@Input() withScrollUp = true;
|
||||
|
||||
constructor(public featureFlags: FeatureFlagsService) {}
|
||||
|
||||
get pageLayoutTitleClasses() {
|
||||
return {
|
||||
'page-layout__title': true,
|
||||
'page-layout__title--fullwidth':
|
||||
!this.featureFlags.FEEDBACK_BUTTON_AVAILABLE,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FeedbackFormComponent } from '@components/page/feedback/form/feedback-form.component';
|
||||
import { ToolkitModule } from '@toolkit/toolkit.module';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { BreadcrumbModule } from 'primeng/breadcrumb';
|
||||
import { PageBreadcrumbsComponent } from './breadcrumds/page-breadcrumbs.component';
|
||||
import { FeedbackButtonComponent } from './feedback/button/feedback-button.component';
|
||||
import { PageLayoutComponent } from './layout/page-layout.component';
|
||||
import { ScrollUpButtonComponent } from './scroll-up-button/scroll-up-button.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
PageLayoutComponent,
|
||||
FeedbackButtonComponent,
|
||||
FeedbackFormComponent,
|
||||
PageBreadcrumbsComponent,
|
||||
ScrollUpButtonComponent,
|
||||
],
|
||||
imports: [CommonModule, ToolkitModule, TranslateModule, BreadcrumbModule],
|
||||
exports: [PageLayoutComponent],
|
||||
})
|
||||
export class PageModule {}
|
||||
@@ -0,0 +1,4 @@
|
||||
<div id="observer-target"></div>
|
||||
<div [ngClass]="buttonClasses">
|
||||
<div class="scroll-up__button" (click)="handleClick()"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,45 @@
|
||||
@use "./src/styles/colors";
|
||||
@use "./src/styles/screen";
|
||||
|
||||
.scroll-up {
|
||||
display: none;
|
||||
|
||||
&--visible {
|
||||
display: block;
|
||||
position: fixed;
|
||||
right: 30px;
|
||||
bottom: 80px;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
&__button {
|
||||
position: static;
|
||||
margin: 0;
|
||||
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
background-color: colors.$extra-blue;
|
||||
background-image: url("~src/assets/img/arrow-up.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
|
||||
border-radius: 50%;
|
||||
border: 1px solid colors.$blue-extra-light;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
#observer-target {
|
||||
position: absolute;
|
||||
|
||||
@include screen.gt-mobile {
|
||||
top: -20px;
|
||||
|
||||
width: 10px;
|
||||
height: 70px;
|
||||
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'scroll-up-button',
|
||||
templateUrl: './scroll-up-button.component.html',
|
||||
styleUrls: ['./scroll-up-button.component.scss'],
|
||||
})
|
||||
export class ScrollUpButtonComponent implements OnInit, OnDestroy {
|
||||
buttonVisible = false;
|
||||
observer: IntersectionObserver;
|
||||
interval: number;
|
||||
|
||||
constructor(private cdRef: ChangeDetectorRef) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.observer = new IntersectionObserver(
|
||||
this.handleIntersection.bind(this),
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: [1],
|
||||
},
|
||||
);
|
||||
|
||||
this.observer.observe(document.querySelector('#observer-target'));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
this.interval = setInterval(() => {
|
||||
// need to check if visibility changed because of programmatic scroll in scrollTo directive
|
||||
this.checkVisibility();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.observer.disconnect();
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
setButtonVisible(visible: boolean) {
|
||||
this.buttonVisible = visible;
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
|
||||
checkVisibility() {
|
||||
if (this.buttonVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const header = document.querySelector('.page-layout__header');
|
||||
const { bottom } = header.getBoundingClientRect();
|
||||
|
||||
this.setButtonVisible(bottom < 0);
|
||||
}
|
||||
|
||||
handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||
const entry = entries[0];
|
||||
|
||||
// entry.boundingClientRect.top < 0 means that observable target crossed
|
||||
// top of the viewport. If top > 0 - target crossed the bottom of the
|
||||
// viewport, and we don't need to show button.
|
||||
const visible =
|
||||
entry.intersectionRatio !== 1 && entry.boundingClientRect.top < 0;
|
||||
this.setButtonVisible(visible);
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
const body = document.querySelector('body');
|
||||
// const bannerTop = document.querySelector('.wrapper-header');
|
||||
const flightsRoot = document.querySelector('flights-root');
|
||||
const { top } = flightsRoot.getBoundingClientRect();
|
||||
|
||||
body.scrollTo(0, body.scrollTop + top);
|
||||
this.setButtonVisible(false);
|
||||
}
|
||||
|
||||
get buttonClasses() {
|
||||
return {
|
||||
'scroll-up': true,
|
||||
'scroll-up--visible': this.buttonVisible,
|
||||
};
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||
import { ActivatedRoute, UrlSegment } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'same-url-navigation-detector',
|
||||
template: '<ng-container></ng-container>',
|
||||
})
|
||||
export class SameUrlNavigationDetectorComponent implements OnInit {
|
||||
private segments: UrlSegment[] = [];
|
||||
|
||||
@Output() sameUrlNavigation = new EventEmitter<void>();
|
||||
|
||||
constructor(protected route: ActivatedRoute) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.observeSameUrlNavigation();
|
||||
}
|
||||
|
||||
private observeSameUrlNavigation() {
|
||||
this.route.url.subscribe({
|
||||
next: (segments) => {
|
||||
if (this.areSegmentsEqual(segments)) {
|
||||
this.sameUrlNavigation.emit();
|
||||
}
|
||||
|
||||
this.segments = segments;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private areSegmentsEqual(newSegments: UrlSegment[]) {
|
||||
if (this.segments.length !== newSegments.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.segments.every((segment, index) => {
|
||||
return segment.path === newSegments[index].path;
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<div class="row-city">SU {{ params.flightNumber }}{{ params.suffix }}</div>
|
||||
<div class="description-row">
|
||||
<span class="description">
|
||||
{{ params.date | aflDate }}
|
||||
</span>
|
||||
</div>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Component, Input, ViewEncapsulation } from '@angular/core';
|
||||
import { ISearchHistoryItem } from '@shared/services/history/search-history.service';
|
||||
import { IParsedFlightId } from '@typings/flight/flight-id';
|
||||
|
||||
@Component({
|
||||
selector: 'online-board-flight-number-history-item',
|
||||
templateUrl: './online-board-flight-number-history-item.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class OnlineBoardFlightNumberHistoryItemComponent {
|
||||
@Input() item: ISearchHistoryItem;
|
||||
|
||||
get params() {
|
||||
return this.item.params as IParsedFlightId;
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<online-board-route-history-item
|
||||
*ngIf="!isFlightNumberSearch"
|
||||
[item]="item"
|
||||
></online-board-route-history-item>
|
||||
<online-board-flight-number-history-item
|
||||
*ngIf="isFlightNumberSearch"
|
||||
[item]="item"
|
||||
></online-board-flight-number-history-item>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { Component, Input, ViewEncapsulation } from '@angular/core';
|
||||
import { ISearchHistoryItem } from '@shared/services/history/search-history.service';
|
||||
import { IParsedFlightId } from '@typings/flight/flight-id';
|
||||
|
||||
@Component({
|
||||
selector: 'online-board-history-item',
|
||||
templateUrl: './online-board-history-item.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class OnlineBoardHistoryItemComponent {
|
||||
@Input() item: ISearchHistoryItem;
|
||||
|
||||
get isFlightNumberSearch(): boolean {
|
||||
const params = this.item.params as IParsedFlightId;
|
||||
|
||||
return !!params.flightNumber;
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<div class="row-city" *ngIf="arrival && departure">
|
||||
{{ departure | cityName }} —
|
||||
{{ arrival | cityName }}
|
||||
</div>
|
||||
<div class="row-city" *ngIf="arrival && !departure">
|
||||
<span class="description">{{ 'BOARD.ARRIVAL' | translate }}</span>
|
||||
{{ arrival | cityName }}
|
||||
</div>
|
||||
<div class="row-city" *ngIf="!arrival && departure">
|
||||
<span class="description">{{ 'BOARD.DEPARTURE' | translate }}</span>
|
||||
{{ departure | cityName }}
|
||||
</div>
|
||||
<div class="description-row">
|
||||
<span class="description">
|
||||
{{ date | aflDate }}
|
||||
</span>
|
||||
<span class="description" *ngIf="timeRange">
|
||||
<span>{{ timeRange | timeRange }}</span>
|
||||
</span>
|
||||
</div>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Component, Input, ViewEncapsulation } from '@angular/core';
|
||||
import { IOnlineBoardRoutePageUrlParams } from '@online-board/services/url/types';
|
||||
import { ISearchHistoryItem } from '@shared/services/history/search-history.service';
|
||||
import { IUrlTimeRange } from '@typings/common/url';
|
||||
|
||||
@Component({
|
||||
selector: 'online-board-route-history-item',
|
||||
templateUrl: './online-board-route-history-item.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class OnlineBoardRouteHistoryItemComponent {
|
||||
@Input() item: ISearchHistoryItem;
|
||||
|
||||
get params() {
|
||||
return this.item.params as IOnlineBoardRoutePageUrlParams;
|
||||
}
|
||||
|
||||
get arrival() {
|
||||
return this.params.arrival;
|
||||
}
|
||||
|
||||
get departure() {
|
||||
return this.params.departure;
|
||||
}
|
||||
|
||||
get date() {
|
||||
return this.params.date;
|
||||
}
|
||||
|
||||
get timeRange(): IUrlTimeRange {
|
||||
const { timeTo, timeFrom } = this.params;
|
||||
return timeFrom && timeTo
|
||||
? {
|
||||
timeFrom,
|
||||
timeTo,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<div class="description-row">
|
||||
<span class="description">{{ title }}</span>
|
||||
</div>
|
||||
<div class="row-city">
|
||||
{{ departure | cityName }} —
|
||||
{{ arrival | cityName }}
|
||||
</div>
|
||||
<div class="description-row">
|
||||
<span class="description">
|
||||
{{ params.outbound.dateFrom | aflDate }} -
|
||||
{{ params.outbound.dateTo | aflDate }}
|
||||
<ng-container *ngIf="params.inbound"> / </ng-container>
|
||||
</span>
|
||||
<span class="description" *ngIf="params.inbound">
|
||||
{{ params.inbound.dateFrom | aflDate }} -
|
||||
{{ params.inbound.dateTo | aflDate }}
|
||||
</span>
|
||||
</div>
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { Component, Input, OnChanges, ViewEncapsulation } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { IScheduleRouteParams } from '@schedule/services/url/types';
|
||||
import { ISearchHistoryItem } from '@shared/services/history/search-history.service';
|
||||
|
||||
@Component({
|
||||
selector: 'schedule-history-item',
|
||||
templateUrl: './schedule-history-item.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class ScheduleHistoryItemComponent implements OnChanges {
|
||||
@Input() item: ISearchHistoryItem;
|
||||
|
||||
title: string;
|
||||
|
||||
constructor(private translate: TranslateService) {}
|
||||
|
||||
ngOnChanges() {
|
||||
this.title = this.getTitle();
|
||||
}
|
||||
|
||||
get params() {
|
||||
return this.item.params as IScheduleRouteParams;
|
||||
}
|
||||
|
||||
get arrival() {
|
||||
return this.params.outbound.arrival;
|
||||
}
|
||||
|
||||
get departure() {
|
||||
return this.params.outbound.departure;
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
const { outbound, inbound } = this.params;
|
||||
|
||||
let result = this.translate.instant('SCHEDULE.TITLE');
|
||||
|
||||
if (!inbound) {
|
||||
result += ', ' + this.translate.instant('SHARED.PART_ONE_WAY');
|
||||
} else {
|
||||
result += ', ' + this.translate.instant('SHARED.THERE_AND_BACK');
|
||||
}
|
||||
|
||||
if (outbound.connections === 0) {
|
||||
result += ', ' + this.translate.instant('SHARED.ONLY_DIRECT');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<div class="row search-history-item">
|
||||
<div class="row-icon search-history-item__icon">
|
||||
<plane-icon
|
||||
*ngIf="item.type !== 'schedule-route'"
|
||||
pTooltip="{{ 'SHARED.LAST-SEARCH-BOARD' | translate }}"
|
||||
tooltipPosition="top"
|
||||
tooltipStyleClass="afl-tooltip"
|
||||
></plane-icon>
|
||||
<alarm-clock-icon
|
||||
*ngIf="item.type === 'schedule-route'"
|
||||
pTooltip="{{ 'SHARED.LAST-SEARCH-SCHEDULE' | translate }}"
|
||||
tooltipPosition="top"
|
||||
tooltipStyleClass="afl-tooltip"
|
||||
></alarm-clock-icon>
|
||||
</div>
|
||||
<div class="row-data search-history-item__data">
|
||||
<schedule-history-item
|
||||
*ngIf="item.type === 'schedule-route'"
|
||||
[item]="item"
|
||||
></schedule-history-item>
|
||||
<online-board-history-item
|
||||
*ngIf="item.type !== 'schedule-route'"
|
||||
[item]="item"
|
||||
></online-board-history-item>
|
||||
</div>
|
||||
</div>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
@use 'src/styles/colors';
|
||||
|
||||
.search-history-item {
|
||||
display: flex !important;
|
||||
|
||||
&:hover .search-history-item__icon svg {
|
||||
fill: colors.$blue;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
&__data {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
|
||||
.description-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.description {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { Component, Input, ViewEncapsulation } from '@angular/core';
|
||||
import { ISearchHistoryItem } from '@shared/services/history/search-history.service';
|
||||
|
||||
@Component({
|
||||
selector: 'search-history-item',
|
||||
templateUrl: './search-history-item.component.html',
|
||||
styleUrls: ['./search-history-item.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class SearchHistoryItemComponent {
|
||||
@Input() item: ISearchHistoryItem;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<section class="frame search-history" *ngIf="historyItems.length">
|
||||
<p-accordion expandIcon="" collapseIcon="">
|
||||
<p-accordionTab>
|
||||
<p-header>
|
||||
{{ 'BOARD.YOU_SEARCH' | translate }}
|
||||
<arrow-down-icon></arrow-down-icon>
|
||||
</p-header>
|
||||
|
||||
<div class="search-history__content">
|
||||
<div
|
||||
*ngFor="let item of historyItems"
|
||||
(click)="openHistoryUrl(item)"
|
||||
>
|
||||
<search-history-item [item]="item"></search-history-item>
|
||||
</div>
|
||||
</div>
|
||||
</p-accordionTab>
|
||||
</p-accordion>
|
||||
</section>
|
||||
@@ -0,0 +1,21 @@
|
||||
@use "src/styles/variables" as *;
|
||||
@use "src/styles/screen" as *;
|
||||
|
||||
.search-history {
|
||||
margin: $space-xl 0;
|
||||
|
||||
@include tablets() {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@include mobile() {
|
||||
margin-top: $space-m;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
max-height: 600px;
|
||||
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Component, Inject, ViewEncapsulation } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
FlightRequestType,
|
||||
ViewType,
|
||||
} from '@shared/enumerators/flight-request-type.enum';
|
||||
import { AppSettings } from '@shared/models-legacy';
|
||||
import { APP_SETTINGS } from '@shared/services';
|
||||
import {
|
||||
ISearchHistoryItem,
|
||||
SearchHistoryService,
|
||||
} from '@shared/services/history/search-history.service';
|
||||
|
||||
@Component({
|
||||
selector: 'search-history',
|
||||
templateUrl: './search-history.component.html',
|
||||
styleUrls: ['./search-history.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class SearchHistoryComponent {
|
||||
ViewType = ViewType;
|
||||
FlightRequestType = FlightRequestType;
|
||||
|
||||
constructor(
|
||||
@Inject(APP_SETTINGS) public settings: AppSettings,
|
||||
private historyService: SearchHistoryService,
|
||||
private router: Router,
|
||||
) {}
|
||||
|
||||
get historyItems() {
|
||||
return this.historyService.items;
|
||||
}
|
||||
|
||||
openHistoryUrl(item: ISearchHistoryItem) {
|
||||
return this.router.navigateByUrl(item.url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { SearchHistoryComponent } from '@components/search-history/search-history.component';
|
||||
import { PipesModule } from '@shared/pipes/pipes.module';
|
||||
import { ToolkitModule } from '@toolkit/toolkit.module';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
import { OnlineBoardHistoryItemComponent } from './components/online-board-item/online-board-history-item.component';
|
||||
import { ScheduleHistoryItemComponent } from './components/schedule-item/schedule-history-item.component';
|
||||
import { SearchHistoryItemComponent } from './components/search-history-item/search-history-item.component';
|
||||
import { OnlineBoardRouteHistoryItemComponent } from './components/online-board-route-history-item/online-board-route-history-item.component';
|
||||
import { OnlineBoardFlightNumberHistoryItemComponent } from './components/online-board-flight-number-history-item/online-board-flight-number-history-item.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
SearchHistoryComponent,
|
||||
OnlineBoardHistoryItemComponent,
|
||||
ScheduleHistoryItemComponent,
|
||||
SearchHistoryItemComponent,
|
||||
OnlineBoardRouteHistoryItemComponent,
|
||||
OnlineBoardFlightNumberHistoryItemComponent,
|
||||
],
|
||||
exports: [SearchHistoryComponent],
|
||||
imports: [CommonModule, ToolkitModule, AccordionModule, PipesModule],
|
||||
})
|
||||
export class SearchHistoryModule {}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, Input, OnChanges } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { RouteType } from '@typings/enums';
|
||||
import { IFlight } from '@typings/flight/flight';
|
||||
import { UrlBuilderService } from '@shared/services/url/url-builder.service';
|
||||
import { getFlightArrivalCity } from '@utils/flight/arrival/city';
|
||||
import { getFlightDepartureCity } from '@utils/flight/departure/city';
|
||||
|
||||
@Component({
|
||||
selector: 'details-title-base',
|
||||
template: '',
|
||||
})
|
||||
export abstract class DetailsTitleBase implements OnChanges {
|
||||
@Input() flight: IFlight;
|
||||
title: string;
|
||||
|
||||
protected constructor(
|
||||
protected translatePipe: TranslatePipe,
|
||||
protected urlService: UrlBuilderService,
|
||||
) {}
|
||||
|
||||
ngOnChanges() {
|
||||
this.title = this.translate();
|
||||
}
|
||||
|
||||
protected abstract get titleKey(): string;
|
||||
protected abstract get pluralTitleKey(): string;
|
||||
protected abstract translatePluralTitle(): string;
|
||||
protected abstract translateTitle(): string;
|
||||
|
||||
protected get base() {
|
||||
return this.isConnecting
|
||||
? this.translatePipe.transform(this.pluralTitleKey)
|
||||
: this.translatePipe.transform(this.titleKey);
|
||||
}
|
||||
|
||||
protected get flightInfo() {
|
||||
if (this.flight.routeType !== RouteType.CONNECTING) {
|
||||
return this.urlService.formatFlightId(this.flight.flightId);
|
||||
}
|
||||
|
||||
return this.flight.flights
|
||||
.map((flight) => this.urlService.formatFlightId(flight.flightId))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
protected get departure() {
|
||||
return getFlightDepartureCity(this.flight);
|
||||
}
|
||||
|
||||
protected get arrival() {
|
||||
return getFlightArrivalCity(this.flight);
|
||||
}
|
||||
|
||||
private translate(): string {
|
||||
if (!this.flight) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.isConnecting
|
||||
? this.translatePluralTitle()
|
||||
: this.translateTitle();
|
||||
}
|
||||
|
||||
private get isConnecting() {
|
||||
return this.flight.routeType === RouteType.CONNECTING;
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<div class="map-wrapper">
|
||||
<div id="map" class="map"></div>
|
||||
<loader-sheet *ngIf="isLoading"></loader-sheet>
|
||||
<no-directions-sheet
|
||||
*ngIf="isNoDirections && !isLoading"
|
||||
(dismiss)="hideNoDirections()">
|
||||
</no-directions-sheet>
|
||||
</div>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
.map-wrapper {
|
||||
position: relative;
|
||||
height: 800px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
+780
@@ -0,0 +1,780 @@
|
||||
import { AfterViewInit, Component, OnInit } from '@angular/core';
|
||||
import { DictionariesService } from '@app/modules/components/page-filters/services/dictionaries-service';
|
||||
import { FlightsMapFiltersStateService, IFlightsMapFilterState } from '@app/shared/services/filters/flights-map-filters-state.service';
|
||||
import { environment } from '@environment';
|
||||
import * as L from 'leaflet';
|
||||
import { from, of, Subject, Subscription } from 'rxjs';
|
||||
import { catchError, distinctUntilChanged, finalize, map, switchMap, takeUntil, tap } from 'rxjs/operators';
|
||||
import { FlightsMapApiService, IDestinationsRequestParams } from '../../services/flights-map-api.service';
|
||||
import { CityCategoryService } from '../../services/category-city.service';
|
||||
import { IDestinationsResponse } from '@typings/responses';
|
||||
import * as moment from 'moment';
|
||||
import { CityModel } from '@app/modules/components/page-filters/models';
|
||||
import { IDestinationResponse } from '../../../../../typings/responses';
|
||||
import { airports } from '@app/shared/services';
|
||||
|
||||
|
||||
export const markerBlue = L.icon({
|
||||
iconUrl: 'assets/img/leaflet/marker-blue.png',
|
||||
iconSize: [15, 15],
|
||||
iconAnchor: [5, 5],
|
||||
popupAnchor: [0, -10]
|
||||
});
|
||||
|
||||
export const markerBlueSmall = L.icon({
|
||||
iconUrl: 'assets/img/leaflet/marker-blue-small.png',
|
||||
iconSize: [11, 11],
|
||||
iconAnchor: [5, 5],
|
||||
popupAnchor: [0, -10]
|
||||
});
|
||||
|
||||
export const markerOrange = L.icon({
|
||||
iconUrl: 'assets/img/leaflet/marker-orange.png',
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
popupAnchor: [0, -20]
|
||||
});
|
||||
|
||||
const directRoutePolyLine : L.PolylineOptions =
|
||||
{
|
||||
color : '#2457ff',
|
||||
weight : 1,
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
const dashRoutePolyLine: L.PolylineOptions =
|
||||
{
|
||||
color : '#2433ff',
|
||||
weight : 1,
|
||||
opacity : 1,
|
||||
dashArray: '4 14'
|
||||
}
|
||||
|
||||
type CountryType = 'ru' | 'other';
|
||||
|
||||
@Component({
|
||||
selector: 'flights-map-body',
|
||||
templateUrl: './flights-map-body.component.html',
|
||||
styleUrls: ['./flights-map-body.component.scss']
|
||||
})
|
||||
export class FlightsMapBodyComponent implements OnInit, AfterViewInit {
|
||||
|
||||
private currentFilterState: IFlightsMapFilterState;
|
||||
|
||||
private map!: L.Map
|
||||
|
||||
/** код города ИЛИ код аэропорта → маркер */
|
||||
private markerIndex = new Map<string, L.Marker>();
|
||||
|
||||
private airportToCityCode = new Map<string, string>();
|
||||
|
||||
private highlighted: Set<L.Marker> = new Set<L.Marker>();
|
||||
private highlightedLayer: L.LayerGroup<L.Marker>;
|
||||
|
||||
private zoomLayers: Record<CountryType, Record<number, L.LayerGroup>> = { ru: {}, other: {} };
|
||||
|
||||
private departurePopup?: L.Popup;
|
||||
private routePopup?: L.Popup;
|
||||
|
||||
private destinationsSub?: Subscription;
|
||||
private destinationsLayer: L.LayerGroup;
|
||||
private destinations: IDestinationsResponse = { data : { routes: []} };
|
||||
|
||||
private skipNextFetchOnce = false; //флаг для пропуска повторного запроса при синхронизации UI-фильтра
|
||||
|
||||
private destroy$ = new Subject<void>()
|
||||
|
||||
isLoading = true;
|
||||
isNoDirections: boolean;
|
||||
|
||||
constructor(
|
||||
private dictService: DictionariesService,
|
||||
private cityCategoryService: CityCategoryService,
|
||||
private filterStateService: FlightsMapFiltersStateService,
|
||||
private apiService: FlightsMapApiService
|
||||
) { }
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
}
|
||||
|
||||
async ngAfterViewInit(): Promise<void> {
|
||||
await this.dictService.ready$;
|
||||
|
||||
this.initMap();
|
||||
this.initMarkers();
|
||||
this.watchRouteChanges();
|
||||
this.watchDateChanges();
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private initMap() {
|
||||
const baseMapURl = environment.mapApiUrl;
|
||||
|
||||
const southWest: L.LatLngExpression = [ -70, -185 ];
|
||||
const northEast: L.LatLngExpression = [ 80, 200 ];
|
||||
|
||||
|
||||
this.map = L.map('map',
|
||||
{
|
||||
center: [53, 45],
|
||||
zoom: 5,
|
||||
attributionControl: false,
|
||||
maxBounds: [ southWest, northEast ],
|
||||
maxBoundsViscosity: 1
|
||||
});
|
||||
|
||||
[2, 3, 4, 5, 6].forEach(z => {
|
||||
this.zoomLayers.ru[z] = L.layerGroup();
|
||||
this.zoomLayers.other[z] = L.layerGroup();
|
||||
});
|
||||
|
||||
L.tileLayer(baseMapURl, {
|
||||
maxZoom: 6,
|
||||
minZoom: 3,
|
||||
}).addTo(this.map);
|
||||
|
||||
this.destinationsLayer = L.layerGroup().addTo(this.map);
|
||||
this.highlightedLayer = L.layerGroup().addTo(this.map);
|
||||
}
|
||||
|
||||
private initMarkers() {
|
||||
this.dictService.citiesAll.forEach(city => {
|
||||
|
||||
if (city.location?.lat == null || city.location?.lon == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = L.marker(
|
||||
[city.location.lat, city.location.lon],
|
||||
{
|
||||
icon: markerBlueSmall,
|
||||
title: city.code,
|
||||
}
|
||||
)
|
||||
.on('click', ()=> this.handleMarkerClick(city.code))
|
||||
.bindTooltip(city.name, {
|
||||
permanent : true,
|
||||
direction : 'top',
|
||||
className : 'city-label'
|
||||
});
|
||||
|
||||
const countryType: CountryType = city.country_code === 'RU' ? 'ru' : 'other';
|
||||
const zMin = this.cityCategoryService.zoomLevel(city.code);
|
||||
|
||||
this.zoomLayers[countryType][zMin].addLayer(marker);
|
||||
|
||||
this.markerIndex.set(city.code, marker);
|
||||
});
|
||||
|
||||
this.dictService.airportsAll?.forEach(airport => {
|
||||
const cityMarker = this.markerIndex.get(airport.city_code);
|
||||
|
||||
if (!cityMarker) return;
|
||||
|
||||
this.markerIndex.set(airport.code, cityMarker);
|
||||
this.airportToCityCode.set(airport.code, airport.city_code);
|
||||
});
|
||||
|
||||
this.updateVisibility();
|
||||
this.map.on('zoomend', () => this.updateVisibility());
|
||||
|
||||
}
|
||||
|
||||
private getMarkerByAnyCode(code: string): L.Marker | undefined {
|
||||
return this.markerIndex.get(code);
|
||||
}
|
||||
|
||||
private updateHighlight(state: IFlightsMapFilterState): void {
|
||||
|
||||
this.highlighted.forEach(marker =>
|
||||
{
|
||||
marker.setIcon(markerBlueSmall);
|
||||
|
||||
const code = (marker as any).options?.title as string;
|
||||
const zlvl = this.cityCategoryService.zoomLevel(code);
|
||||
const countryType = this.dictService.getCityByCode(code).country_code === 'RU' ? 'ru': 'other';
|
||||
|
||||
this.moveBetweenLayer(marker, this.highlightedLayer, this.zoomLayers[countryType][zlvl]);
|
||||
});
|
||||
|
||||
this.highlighted.clear();
|
||||
this.highlightedLayer.clearLayers();
|
||||
|
||||
const codes = [state.departure, state.arrival].filter(Boolean) as string[];
|
||||
|
||||
codes.forEach(code => {
|
||||
const marker = this.getMarkerByAnyCode(code);
|
||||
|
||||
if (!marker) { return; }
|
||||
|
||||
marker.setIcon(markerOrange);
|
||||
this.highlighted.add(marker);
|
||||
|
||||
const zLvl = this.cityCategoryService.zoomLevel(code);
|
||||
const countryType = this.dictService.getCityByCode(code).country_code === 'RU' ? 'ru': 'other';
|
||||
this.moveBetweenLayer(marker, this.zoomLayers[countryType][zLvl], this.highlightedLayer);
|
||||
});
|
||||
|
||||
this.updateVisibility();
|
||||
}
|
||||
|
||||
private updateVisibility(): void
|
||||
{
|
||||
this.updateMarkers();
|
||||
this.updateHighlightedTooltips();
|
||||
|
||||
// if(this.currentFilterState){
|
||||
// this.fetchAndDraw(this.currentFilterState);
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
private updateMarkers() {
|
||||
const z = this.map.getZoom();
|
||||
|
||||
(['ru', 'other'] as const).forEach(countryType => {
|
||||
|
||||
|
||||
Object.entries(this.zoomLayers[countryType]).forEach(([lvl, layer]) =>
|
||||
{
|
||||
|
||||
const layerShouldBeVisible = +lvl <= z;
|
||||
|
||||
if(countryType === 'ru')
|
||||
{
|
||||
|
||||
if(!this.currentFilterState?.international && layerShouldBeVisible)
|
||||
{
|
||||
this.map.addLayer(layer);
|
||||
}
|
||||
else {
|
||||
this.map.removeLayer(layer);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if(!this.currentFilterState?.domestic && layerShouldBeVisible)
|
||||
{
|
||||
this.map.addLayer(layer);
|
||||
}
|
||||
else {
|
||||
this.map.removeLayer(layer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private updateHighlightedTooltips() {
|
||||
const z = this.map.getZoom();
|
||||
|
||||
if(z <= 3)
|
||||
{
|
||||
this.markerIndex.forEach((m) => {
|
||||
|
||||
if(this.highlighted.has(m))
|
||||
{
|
||||
return;
|
||||
}
|
||||
m.closeTooltip();
|
||||
});
|
||||
}
|
||||
else if(this.highlighted.size >= 2)
|
||||
{
|
||||
this.markerIndex.forEach((m) => {
|
||||
|
||||
if(this.highlighted.has(m))
|
||||
{
|
||||
return;
|
||||
}
|
||||
m.closeTooltip();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
this.markerIndex.forEach((m) => {
|
||||
m.openTooltip();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateIntermediateTooltip() {
|
||||
const routes = this.destinations?.data.routes;
|
||||
|
||||
if(!routes) {return;}
|
||||
|
||||
for (let i = 0; i < routes.length; i++)
|
||||
{
|
||||
const route = routes[i].route;
|
||||
|
||||
if(route.length <= 2){ continue; }
|
||||
|
||||
for (let cityIndex = 1; cityIndex < route.length - 1; cityIndex++)
|
||||
{
|
||||
const intermediateCityCode = route[cityIndex];
|
||||
|
||||
const intermediateCityMarker = this.getMarkerByAnyCode(intermediateCityCode);
|
||||
|
||||
if(!intermediateCityMarker) {continue;}
|
||||
|
||||
intermediateCityMarker.openTooltip();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private watchRouteChanges() {
|
||||
this.filterStateService.state$
|
||||
.pipe(
|
||||
distinctUntilChanged(
|
||||
(a, b) => a.departure === b.departure &&
|
||||
a.arrival === b.arrival &&
|
||||
a.connections === b.connections &&
|
||||
a.domestic === b.domestic &&
|
||||
a.international === b.international
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
tap(_=> this.isLoading = true)
|
||||
)
|
||||
.subscribe(state => {
|
||||
this.currentFilterState = state;
|
||||
this.updateHighlight(state);
|
||||
|
||||
this.hideNoDirections();
|
||||
|
||||
if (this.skipNextFetchOnce) {
|
||||
this.skipNextFetchOnce = false; // пропускаем ровно один раз
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetchAndDraw(state);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private watchDateChanges() {
|
||||
this.filterStateService.state$
|
||||
.pipe(
|
||||
distinctUntilChanged(
|
||||
(a, b) => a.date === b.date
|
||||
),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe(state => {
|
||||
this.currentFilterState = state;
|
||||
|
||||
if(this.destinations?.data?.routes?.length > 0)
|
||||
{
|
||||
this.showRoutePopup(this.destinations.data.routes);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private handleMarkerClick(cityCode: string) : void
|
||||
{
|
||||
if(!this.currentFilterState.departure)
|
||||
{
|
||||
this.filterStateService.setDeparture(cityCode);
|
||||
}
|
||||
else if(this.currentFilterState.departure && !this.currentFilterState.arrival)
|
||||
{
|
||||
if (cityCode !== this.currentFilterState.departure)
|
||||
{
|
||||
this.filterStateService.setArrival(cityCode);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
this.filterStateService.setDeparture(cityCode);
|
||||
this.filterStateService.setArrival(undefined);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fetchAndDraw(state: IFlightsMapFilterState)
|
||||
{
|
||||
|
||||
this.clearRoutes();
|
||||
this.clearPopup();
|
||||
|
||||
const dateFrom = new Date();
|
||||
dateFrom.setDate(dateFrom.getDate() - 1);
|
||||
dateFrom.setHours(0, 0, 0, 0);
|
||||
|
||||
const dateTo = new Date();
|
||||
dateTo.setMonth(dateFrom.getMonth() + 6);
|
||||
dateTo.setHours(0, 0, 0, 0);
|
||||
|
||||
|
||||
if((state.departure && state.arrival) && (state.departure != state.arrival))
|
||||
{
|
||||
this.fetchAndDrawRoute(state.departure, state.arrival, dateFrom, dateTo, state.connections? 1: 0);
|
||||
}
|
||||
else if(state.departure && !state.arrival)
|
||||
{
|
||||
this.fetchAndDrawSpider(state.departure, dateFrom, dateTo);
|
||||
}else
|
||||
{
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fetchAndDrawRoute(departure: string, arrival: string, dateFrom: Date, dateTo: Date, connections: number) {
|
||||
|
||||
const base: IDestinationsRequestParams = {
|
||||
departure: departure,
|
||||
arrival: arrival,
|
||||
dateFrom: dateFrom,
|
||||
dateTo: dateTo,
|
||||
connections: connections
|
||||
};
|
||||
|
||||
const withConn1: IDestinationsRequestParams = { ...base, connections: 1 };
|
||||
|
||||
this.destinationsSub = this.apiService.getDestinations(base).pipe(
|
||||
switchMap(first => {
|
||||
const hasRoutes = !!first?.data?.routes?.length;
|
||||
|
||||
if (hasRoutes || base.connections === 1) {
|
||||
return of({ res: first, usedConn: base.connections ?? 0 });
|
||||
}
|
||||
// второй заход с пересадками
|
||||
return this.apiService.getDestinations(withConn1).pipe(
|
||||
map((second: IDestinationsResponse) => {
|
||||
|
||||
const hasSecond = !!second?.data?.routes?.length;
|
||||
|
||||
return {
|
||||
res: hasSecond ? second : first,
|
||||
usedConn: hasSecond ? 1 : 0
|
||||
};
|
||||
})
|
||||
);
|
||||
}),
|
||||
tap(({ usedConn }) => {
|
||||
// если фолбэк (показывать с пересадкой) дал маршруты и в UI ещё выключены пересадки — включим их
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
if (usedConn === 1 && !this.currentFilterState.connections) {
|
||||
this.skipNextFetchOnce = true; // не дёргать повторно fetch
|
||||
this.filterStateService.setConnections(true); // обновим UI-состояние
|
||||
}
|
||||
}),
|
||||
map(res => this.filterRoutes(res.res)),
|
||||
catchError(() => of<IDestinationsResponse>({ data: { routes: [] } })),
|
||||
takeUntil(this.destroy$),
|
||||
finalize(() => (this.isLoading = false))
|
||||
)
|
||||
.subscribe(destinations => {
|
||||
this.destinations = destinations;
|
||||
this.buildRoute(destinations);
|
||||
this.updateIntermediateTooltip();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private fetchAndDrawSpider(cityFromCode: string, dateFrom: Date, dateTo: Date)
|
||||
{
|
||||
const params: IDestinationsRequestParams = {
|
||||
departure : cityFromCode,
|
||||
dateFrom: dateFrom,
|
||||
dateTo: dateTo
|
||||
};
|
||||
|
||||
this.currentFilterState.connections = false;
|
||||
this.destinationsSub = this.getDestinations(params)
|
||||
.pipe(
|
||||
finalize(() => (this.isLoading = false))
|
||||
)
|
||||
.subscribe(destinations =>
|
||||
this.buildSpider(destinations)
|
||||
);
|
||||
}
|
||||
|
||||
private getDestinations(params: IDestinationsRequestParams) {
|
||||
return this.apiService.getDestinations(params).pipe(
|
||||
catchError(() => of<IDestinationsResponse>({
|
||||
data: { routes: [] }
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
private filterRoutes(res: IDestinationsResponse): IDestinationsResponse {
|
||||
const routes = res?.data?.routes ?? [];
|
||||
const { domestic, international, connections } = this.currentFilterState;
|
||||
|
||||
const isDomestic = (r: { route: string[] }) =>
|
||||
r.route.every(code => this.dictService.ruCitiesCodes.has(this.dictService.getCityCodeByAirportCode(code)));
|
||||
|
||||
const isInternational = (r: { route: string[] }) =>
|
||||
r.route.some(code => this.dictService.otherCitiesCodes.has(this.dictService.getCityCodeByAirportCode(code)));
|
||||
|
||||
const hasConnections = (r: { isDirect: boolean }) => !r.isDirect;
|
||||
|
||||
const predicates: Array<(r: any) => boolean> = [];
|
||||
|
||||
if (domestic && !international) {
|
||||
predicates.push(isDomestic);
|
||||
} else if (international && !domestic) {
|
||||
predicates.push(isInternational);
|
||||
}
|
||||
|
||||
if (connections) {
|
||||
predicates.push(hasConnections);
|
||||
}
|
||||
|
||||
const filtered = predicates.length
|
||||
? routes.filter(r => predicates.every(p => p(r)))
|
||||
: routes;
|
||||
|
||||
return {
|
||||
...res,
|
||||
data: {
|
||||
...res.data,
|
||||
routes: filtered
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private buildRoute(res: IDestinationsResponse): void
|
||||
{
|
||||
const routes = res.data?.routes ?? [];
|
||||
|
||||
if(routes.length == 0)
|
||||
{
|
||||
this.isNoDirections = true;
|
||||
}
|
||||
|
||||
if (!routes.length) { return; }
|
||||
|
||||
let line : L.Polyline;
|
||||
|
||||
routes.filter(_ => _.isDirect).forEach(path => {
|
||||
this.drawPolyline(path.route, directRoutePolyLine, this.destinationsLayer);
|
||||
});
|
||||
|
||||
routes.filter(_ => !_.isDirect).forEach(path => {
|
||||
this.drawPolyline(path.route, dashRoutePolyLine, this.destinationsLayer);
|
||||
});
|
||||
|
||||
|
||||
this.showRoutePopup(routes);
|
||||
}
|
||||
|
||||
private buildSpider(res: IDestinationsResponse): void
|
||||
{
|
||||
const routes = res.data?.routes ?? [];
|
||||
if (!routes.length) { return; }
|
||||
|
||||
const fromCode = routes[0].route[0];
|
||||
const destCodes = new Set<string>();
|
||||
|
||||
routes.forEach(path => {
|
||||
if (Array.isArray(path.route) && path.route.length > 1)
|
||||
{
|
||||
const dest = path.route[path.route.length - 1];
|
||||
if (dest !== fromCode) { destCodes.add(dest); }
|
||||
}
|
||||
});
|
||||
|
||||
destCodes.forEach(code => {
|
||||
this.drawPolyline([fromCode, code], directRoutePolyLine, this.destinationsLayer);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private drawPolyline(cities: string[], style: L.PolylineOptions, target: L.LayerGroup | L.Map = this.map): L.Polyline | undefined
|
||||
{
|
||||
const segments: L.LatLng[] = [];
|
||||
|
||||
const visibleCities = cities.filter(code => {
|
||||
const m = this.getMarkerByAnyCode(code);
|
||||
return m && this.map.hasLayer(m);
|
||||
});
|
||||
|
||||
if (visibleCities.length < 2) { return; }
|
||||
|
||||
for (let i = 0; i < visibleCities.length - 1; i++) {
|
||||
const from = this.getMarkerByAnyCode(visibleCities[i])!;
|
||||
const to = this.getMarkerByAnyCode(visibleCities[i + 1])!;
|
||||
|
||||
const arc = this.buildGreatCircle(from.getLatLng(), to.getLatLng());
|
||||
segments.push(...(i === 0 ? arc : arc.slice(1)));
|
||||
}
|
||||
|
||||
return L.polyline(segments, style).addTo(target);
|
||||
|
||||
}
|
||||
|
||||
private clearRoutes()
|
||||
{
|
||||
this.destinationsSub?.unsubscribe();
|
||||
this.destinationsLayer?.clearLayers();
|
||||
this.destinations = {data: {routes:[]}};
|
||||
}
|
||||
|
||||
private clearPopup(){
|
||||
if (this.routePopup) {
|
||||
this.map.removeLayer(this.routePopup);
|
||||
this.routePopup = undefined;
|
||||
}
|
||||
|
||||
if(this.departurePopup){
|
||||
this.map.removeLayer(this.departurePopup);
|
||||
this.departurePopup = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private showRoutePopup(rotues: IDestinationResponse[]): void {
|
||||
|
||||
const firstRoute = rotues[0].route;
|
||||
const departureCode = firstRoute[0];
|
||||
const arrivalCode = firstRoute[firstRoute.length -1];
|
||||
|
||||
const markerDeparture = this.getMarkerByAnyCode(departureCode);
|
||||
const markerArrival = this.getMarkerByAnyCode(arrivalCode);
|
||||
if (!markerArrival) { return; }
|
||||
|
||||
|
||||
const cityDeparture = this.dictService.getCityByCode(this.airportToCityCode.get(departureCode));
|
||||
const cityArrival = this.dictService.getCityByCode(this.airportToCityCode.get(arrivalCode));
|
||||
|
||||
if(!cityArrival) { return; }
|
||||
|
||||
|
||||
this.clearPopup();
|
||||
|
||||
const linkUrl = this.getLink();
|
||||
|
||||
//ToDo: translate
|
||||
const buyTicketText = 'Купить билет';
|
||||
|
||||
const htmlDeparture = `
|
||||
<div class="popup-header-test">
|
||||
<span>${cityDeparture.name}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const htmlInermediate = `
|
||||
<div class="popup-header-test">
|
||||
<span>${cityDeparture.name}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const htmlArrival = `
|
||||
<div class="popup-header-test">
|
||||
<span>${cityArrival.name}</span>
|
||||
|
||||
</div>
|
||||
<div style="text-align:center;">
|
||||
<a href="${linkUrl}" target="_blank" class="popup-buy-ticket">
|
||||
${buyTicketText}
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.departurePopup = L.popup({
|
||||
closeButton: true,
|
||||
autoClose: false,
|
||||
closeOnClick: false
|
||||
})
|
||||
.setLatLng(markerDeparture.getLatLng())
|
||||
.setContent(htmlDeparture)
|
||||
.openOn(this.map);
|
||||
|
||||
|
||||
this.routePopup = L.popup({
|
||||
closeButton: true,
|
||||
autoClose: false,
|
||||
closeOnClick: false
|
||||
})
|
||||
.setLatLng(markerArrival.getLatLng())
|
||||
.setContent(htmlArrival)
|
||||
.openOn(this.map);
|
||||
}
|
||||
|
||||
private moveBetweenLayer(marker: L.Marker, from: L.LayerGroup, to: L.LayerGroup)
|
||||
{
|
||||
if (from?.hasLayer(marker)) {
|
||||
from.removeLayer(marker);
|
||||
}
|
||||
|
||||
if (!to?.hasLayer(marker)) {
|
||||
to.addLayer(marker);
|
||||
}
|
||||
}
|
||||
|
||||
private buildGreatCircle(from: L.LatLng, to: L.LatLng, segments = 64): L.LatLng[]
|
||||
{
|
||||
const φ1 = this.deg2rad(from.lat);
|
||||
const λ1 = this.deg2rad(from.lng);
|
||||
const φ2 = this.deg2rad(to.lat);
|
||||
const λ2 = this.deg2rad(to.lng);
|
||||
|
||||
const Δ = 2 * Math.asin(
|
||||
Math.sqrt(
|
||||
Math.sin((φ2 - φ1) / 2) ** 2 +
|
||||
Math.cos(φ1) * Math.cos(φ2) *
|
||||
Math.sin((λ2 - λ1) / 2) ** 2
|
||||
)
|
||||
);
|
||||
|
||||
if (Δ === 0) { return [from, to]; } // совпадают
|
||||
|
||||
const points: L.LatLng[] = [];
|
||||
|
||||
for (let i = 0; i <= segments; i++)
|
||||
{
|
||||
const f = i / segments;
|
||||
const A = Math.sin((1 - f) * Δ) / Math.sin(Δ);
|
||||
const B = Math.sin(f * Δ) / Math.sin(Δ);
|
||||
|
||||
const x =
|
||||
A * Math.cos(φ1) * Math.cos(λ1) +
|
||||
B * Math.cos(φ2) * Math.cos(λ2);
|
||||
|
||||
const y =
|
||||
A * Math.cos(φ1) * Math.sin(λ1) +
|
||||
B * Math.cos(φ2) * Math.sin(λ2);
|
||||
|
||||
const z =
|
||||
A * Math.sin(φ1) +
|
||||
B * Math.sin(φ2);
|
||||
|
||||
const φi = Math.atan2(z, Math.sqrt(x * x + y * y));
|
||||
const λi = Math.atan2(y, x);
|
||||
|
||||
points.push(L.latLng(this.rad2deg(φi), this.rad2deg(λi)));
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private deg2rad(deg: number): number { return (deg * Math.PI) / 180; }
|
||||
private rad2deg(rad: number): number { return (rad * 180) / Math.PI; }
|
||||
|
||||
private getLink(): string {
|
||||
const state = this.currentFilterState;
|
||||
|
||||
const d = new Date();
|
||||
d.setHours(0,0,0,0);
|
||||
|
||||
const date = moment(state.date ?? d).format('YYYYMMDD');
|
||||
const params = `${state.departure}.${date}.${state.arrival}`;
|
||||
|
||||
return `https://www.aeroflot.ru/sb/app/ru-ru#/search?adults=1&cabin=economy&children=0&infants=0&routes=${params}&autosearch=Y&utm_source=aflwebbot&utm_medium=referral&utm_campaign=ref_3015_general_rf_button.index__all_flight.map`;
|
||||
}
|
||||
|
||||
hideNoDirections(): void {
|
||||
this.isNoDirections = false;
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
<section>
|
||||
<p-accordion expandIcon="" collapseIcon="" [activeIndex]="0">
|
||||
<p-accordionTab [selected]="true" [disabled]="true">
|
||||
<div class="flights-map-filter-content">
|
||||
|
||||
<div class="flights-map-filter-header">
|
||||
<h3>{{ 'FLIGHTS-MAP.ROUTE' | translate }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="flights-map-filter-content-cities">
|
||||
<city-autocomplete
|
||||
label="SHARED.DEPARTURE_CITY"
|
||||
[(ngModel)]="departure"
|
||||
[placeholder]="departurePlaceholder"
|
||||
data-testid="route-departure-city-input">
|
||||
</city-autocomplete>
|
||||
|
||||
<div class="change-container">
|
||||
<button
|
||||
class="button-change"
|
||||
pButton
|
||||
type="button"
|
||||
(click)="exchange()">
|
||||
<svg class="svg--change-city">
|
||||
<use xlink:href="/assets/img/sprite.svg#changeCity"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<city-autocomplete
|
||||
label="SHARED.ARRIVAL_CITY"
|
||||
[(ngModel)]="arrival"
|
||||
[placeholder]="arrivalPlaceholder"
|
||||
data-testid="route-arrival-city-input"
|
||||
></city-autocomplete>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flights-map-filter-info">
|
||||
<p>{{'FLIGHTS-MAP.FILTER_INFO' | translate}}</p>
|
||||
</div>
|
||||
|
||||
<div class="flights-map-filter-content-checkboxes">
|
||||
|
||||
<toggle-switch
|
||||
[disabled]="departure ? false : true"
|
||||
[(ngModel)]="domestic"
|
||||
label="{{ 'FLIGHTS-MAP.DOMESTIC_FLIGHTS' | translate }}">
|
||||
</toggle-switch>
|
||||
|
||||
<toggle-switch
|
||||
[disabled]="departure ? false : true"
|
||||
|
||||
[(ngModel)]="international"
|
||||
label="{{ 'FLIGHTS-MAP.INTERNATIONAL_FLIGHTS' | translate }}">
|
||||
</toggle-switch>
|
||||
|
||||
<toggle-switch
|
||||
[disabled]="departure && arrival ? false : true"
|
||||
[(ngModel)]="connections"
|
||||
label="{{ 'FLIGHTS-MAP.CONNECTING_FLIGHTS' | translate }}">
|
||||
</toggle-switch>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flighs-map-filter-date">
|
||||
<calendar-input
|
||||
label="SHARED.FLIGHT_DATE"
|
||||
[(ngModel)]="date"
|
||||
|
||||
[minDate]="minDate"
|
||||
[maxDate]="maxDate"
|
||||
[disabledDates]="disabledDates"
|
||||
data-testid="route-calendar-input"
|
||||
>
|
||||
</calendar-input>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</p-accordionTab>
|
||||
</p-accordion>
|
||||
</section>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
.flights-map-filter-header{
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.flights-map-filter-info{
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.mt2{
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
// .svg--change-city {
|
||||
// transform: rotate(180deg) !important;
|
||||
// }
|
||||
|
||||
// .change-container {
|
||||
// justify-content: right !important;
|
||||
// }
|
||||
|
||||
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { Component, OnInit, Input, OnDestroy, ChangeDetectorRef, Inject } from '@angular/core';
|
||||
import { ScheduleFilterValidationService } from '@app/features/schedule/components/schedule-filter/services/schedule-filter-validation.service';
|
||||
import { UserLocationService } from '@app/shared/services/user-location/user-location.service';
|
||||
import { FlightsMapFiltersStateService, IFlightsMapFilterState } from '@shared/services/filters/flights-map-filters-state.service';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { AppSettings } from '@shared/models-legacy';
|
||||
import { APP_SETTINGS } from '@shared/services';
|
||||
import { OnlineBoardApiService } from "@app/features/online-board/services/api.service";
|
||||
import { FlightsMapApiService } from '../../services/flights-map-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'flights-map-filter',
|
||||
templateUrl: './flights-map-filter.component.html',
|
||||
styleUrls: ['./flights-map-filter.component.scss'],
|
||||
providers: [ScheduleFilterValidationService],
|
||||
})
|
||||
export class FlightsMapFilterComponent implements OnInit, OnDestroy {
|
||||
|
||||
private currentFilterState: IFlightsMapFilterState;
|
||||
|
||||
withReturn: boolean;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
public validationService: ScheduleFilterValidationService,
|
||||
private apiService: FlightsMapApiService,
|
||||
@Inject(APP_SETTINGS) public settings: AppSettings,
|
||||
private filterStateService: FlightsMapFiltersStateService,
|
||||
private locationService: UserLocationService,
|
||||
private cdr : ChangeDetectorRef
|
||||
) { }
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
this.filterStateService.state$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(state => {
|
||||
this.currentFilterState = state;
|
||||
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
|
||||
this.locationService.location.subscribe((location) => {
|
||||
// set default state only if user permitted geo position
|
||||
// search and filter isn't filled
|
||||
if (location && !this.departure && !this.arrival) {
|
||||
this.filterStateService.setDeparture(location);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
|
||||
get departure() {
|
||||
return this.currentFilterState.departure;
|
||||
}
|
||||
|
||||
set departure(departure: string)
|
||||
{
|
||||
this.filterStateService.setDeparture(departure);
|
||||
}
|
||||
|
||||
get arrival()
|
||||
{
|
||||
return this.currentFilterState.arrival;
|
||||
}
|
||||
|
||||
set arrival(arrival: string){
|
||||
this.filterStateService.setArrival(arrival);
|
||||
}
|
||||
|
||||
get connections()
|
||||
{
|
||||
return this.currentFilterState.connections;
|
||||
}
|
||||
|
||||
set connections(showConenctions: boolean)
|
||||
{
|
||||
this.filterStateService.setConnections(showConenctions);
|
||||
}
|
||||
|
||||
get domestic()
|
||||
{
|
||||
return this.currentFilterState.domestic;
|
||||
}
|
||||
|
||||
set domestic(showDomestic: boolean)
|
||||
{
|
||||
this.filterStateService.setDomestic(showDomestic);
|
||||
}
|
||||
|
||||
get international()
|
||||
{
|
||||
return this.currentFilterState.international;
|
||||
}
|
||||
|
||||
set international(showInternational: boolean)
|
||||
{
|
||||
this.filterStateService.setInternational(showInternational);
|
||||
}
|
||||
|
||||
get date()
|
||||
{
|
||||
return this.currentFilterState.date;
|
||||
}
|
||||
|
||||
set date(date: Date)
|
||||
{
|
||||
this.filterStateService.setDate(date);
|
||||
}
|
||||
|
||||
get departurePlaceholder() {
|
||||
return 'FLIGHTS-MAP.FILTER_DEPARTURE_PLACEHOLDER'
|
||||
}
|
||||
get arrivalPlaceholder() {
|
||||
return 'FLIGHTS-MAP.FILTER_ARRIVAL_PLACEHOLDER'
|
||||
}
|
||||
|
||||
get minDate(){
|
||||
return this.currentFilterState.minDate;
|
||||
}
|
||||
|
||||
get maxDate(){
|
||||
return this.currentFilterState.maxDate;
|
||||
}
|
||||
|
||||
get disabledDates()
|
||||
{
|
||||
return this.currentFilterState.disabledDates;
|
||||
}
|
||||
|
||||
set disabledDates(disabledDates: Date[]){
|
||||
this.filterStateService.setDisabledDates(disabledDates);
|
||||
}
|
||||
|
||||
exchange() {
|
||||
[
|
||||
this.validationService.departureError,
|
||||
this.validationService.arrivalError,
|
||||
] = [null, null];
|
||||
[this.departure, this.arrival] = [this.arrival, this.departure];
|
||||
}
|
||||
|
||||
resetReturnDateRange() {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<meta-tags
|
||||
[title]="'SEO.FLIGHTS-MAP.MAIN.TITLE' | translate"
|
||||
[description]="'SEO.FLIGHTS-MAP.MAIN.DESCRIPTION' | translate"
|
||||
[noRobots]="false"
|
||||
></meta-tags>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'flights-map-meta-tags',
|
||||
templateUrl: './flights-map-meta-tags.component.html',
|
||||
styleUrls: ['./flights-map-meta-tags.component.scss']
|
||||
})
|
||||
export class FlightsMapMetaTagsComponent{}
|
||||
+1
@@ -0,0 +1 @@
|
||||
<aero-title [title]="title | translate"></aero-title>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'flights-map-start-page-title',
|
||||
templateUrl: './flights-map-start-page-title.component.html',
|
||||
styleUrls: ['./flights-map-start-page-title.component.scss']
|
||||
})
|
||||
export class FlightsMapStartPageTitleComponent {
|
||||
|
||||
title = 'FLIGHTS-MAP.TITLE';
|
||||
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<flights-map-meta-tags></flights-map-meta-tags>
|
||||
<div>
|
||||
<page-layout [withScrollOverlay]="false" [withScrollUp]="true">
|
||||
<flights-map-start-page-title title></flights-map-start-page-title>
|
||||
|
||||
<flights-page-tabs
|
||||
[viewType]="ViewType.FlightsMap"
|
||||
header-left
|
||||
></flights-page-tabs>
|
||||
|
||||
<ng-container content-left>
|
||||
<!-- <online-board-filter></online-board-filter>
|
||||
|
||||
<search-history></search-history> -->
|
||||
|
||||
<flights-map-filter></flights-map-filter>
|
||||
</ng-container>
|
||||
|
||||
<ng-container >
|
||||
<section class="frame">
|
||||
<!-- <h2>{{ 'BOARD.BOARD-START' | translate }}</h2> -->
|
||||
<flights-map-body></flights-map-body>
|
||||
</section>
|
||||
</ng-container>
|
||||
|
||||
</page-layout>
|
||||
</div>
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ViewType } from '@shared/enumerators/flight-request-type.enum';
|
||||
|
||||
@Component({
|
||||
selector: 'flights-map-start-page',
|
||||
templateUrl: './flights-map-start-page.component.html',
|
||||
styleUrls: ['./flights-map-start-page.component.scss']
|
||||
})
|
||||
export class FlightsMapStartPageComponent implements OnInit {
|
||||
ViewType = ViewType;
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<!-- Простынка с лоадером -->
|
||||
<div class="loading-sheet">
|
||||
<div class="page-loader__loader">
|
||||
<div class="loader-circle"></div>
|
||||
<div class="loader-line-mask">
|
||||
<div class="loader-line"></div>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 33.795 32.307">
|
||||
<path
|
||||
id="plane_afl_small"
|
||||
d="M20.824,12.956c0,.388,0,.823,0,1.29L35.729,26.527l.007,1.633-15.1-7.255a70.654,70.654,0,0,1-.947,9.23l6.09,3.575.011.462-6.457-2.2c-.139.5-.276.788-.426.785a.561.561,0,0,1-.437-.774,35.562,35.562,0,0,0-6.415,2.288l-.006-.469,6.037-3.658a71.009,71.009,0,0,1-1.149-9.227L1.96,28.408l-.02-1.627,14.708-12.5c-.011-.469-.015-.9-.019-1.291-.048-6.082.858-11.015,2.014-11.021s2.138,4.91,2.18,10.99"
|
||||
transform="translate(36,-2) rotate(90)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
:host {
|
||||
--loader-color: #2b62ab;
|
||||
}
|
||||
|
||||
.loading-sheet {
|
||||
position: absolute;
|
||||
inset: 0; // top:0; right:0; bottom:0; left:0;
|
||||
background: rgba(180, 180, 180, 0.302); // затемнение карты
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000; // поверх карты
|
||||
pointer-events: all; // блокирует клики по карте
|
||||
}
|
||||
|
||||
.page-loader__loader {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: var(--loader-color); // для svg
|
||||
|
||||
.loader-circle {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 2px var(--loader-color);
|
||||
transition-duration: 0.3s;
|
||||
}
|
||||
|
||||
.loader-line-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 30px;
|
||||
height: 60px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
transform-origin: 30px 30px;
|
||||
animation: rotate 1.2s infinite linear;
|
||||
|
||||
.loader-line {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'loader-sheet',
|
||||
templateUrl: './loader-sheet.component.html',
|
||||
styleUrls: ['./loader-sheet.component.scss']
|
||||
})
|
||||
export class LoaderSheetComponent implements OnInit {
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<div class="no-directions-sheet" (click)="dismiss.emit()">
|
||||
<div class="no-directions-card" (click)="$event.stopPropagation()">
|
||||
<p> {{'FLIGHTS-MAP.NO_DIRECTIONS_INFO' | translate}}</p>
|
||||
</div>
|
||||
</div>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
.no-directions-sheet {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(180, 180, 180, 0.302);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
pointer-events: all;
|
||||
padding: 16px; // чтобы не прилипало к краям на мобилках
|
||||
}
|
||||
|
||||
.no-directions-card {
|
||||
background: #fff;
|
||||
padding: 16px 18px;
|
||||
border-radius: 5px;
|
||||
max-width: 520px;
|
||||
width: min(520px, 100%);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.no-directions-card p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: rgba(0, 0, 0, 0.78);
|
||||
text-align: center;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'no-directions-sheet',
|
||||
templateUrl: './no-directions-sheet.component.html',
|
||||
styleUrls: ['./no-directions-sheet.component.scss']
|
||||
})
|
||||
export class NoDirectionsSheetComponent {
|
||||
|
||||
@Output() dismiss = new EventEmitter<void>();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { FlightsMapStartPageComponent } from './components/flights-map-start-page/flights-map-start-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: FlightsMapStartPageComponent
|
||||
}
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class FlightsMapRoutingModule { }
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentsModule } from '@components/components.module';
|
||||
import { ToolkitModule } from '@toolkit/toolkit.module';
|
||||
import { FlightsMapRoutingModule } from './flights-map-routing.module'
|
||||
import { FlightsMapStartPageComponent } from './components/flights-map-start-page/flights-map-start-page.component';
|
||||
import { FlightsMapStartPageTitleComponent } from './components/flights-map-start-page-title/flights-map-start-page-title.component';
|
||||
import { FlightsModule } from '@modules/flights.module';
|
||||
import { FlightsMapFilterComponent } from './components/flights-map-filter/flights-map-filter.component';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CheckboxModule } from 'primeng/checkbox';
|
||||
import { FlightsMapBodyComponent } from './components/flights-map-body/flights-map-body.component';
|
||||
import { FlightsMapApiService } from './services/flights-map-api.service';
|
||||
import { FlightsMapMetaTagsComponent } from './components/flights-map-meta-tags/flights-map-meta-tags.component';
|
||||
import { SharedModule } from "../../shared/shared.module";
|
||||
import { OnlineBoardApiService } from '../online-board/services/api.service';
|
||||
import { LoaderSheetComponent } from './components/loader-sheet/loader-sheet.component';
|
||||
import { NoDirectionsSheetComponent } from './components/no-directions-sheet/no-directions-sheet.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
FlightsMapStartPageComponent,
|
||||
FlightsMapStartPageTitleComponent,
|
||||
FlightsMapFilterComponent,
|
||||
FlightsMapBodyComponent,
|
||||
FlightsMapMetaTagsComponent,
|
||||
LoaderSheetComponent,
|
||||
NoDirectionsSheetComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FlightsMapRoutingModule,
|
||||
ComponentsModule,
|
||||
ToolkitModule,
|
||||
FlightsModule,
|
||||
AccordionModule,
|
||||
CheckboxModule,
|
||||
SharedModule
|
||||
],
|
||||
providers: [
|
||||
FlightsMapApiService,
|
||||
OnlineBoardApiService,
|
||||
]
|
||||
})
|
||||
export class FlightsMapModule { }
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CityCategoryService {
|
||||
|
||||
private _population1kk = new Set<string>([
|
||||
"QIN","THR","FRU","KZN","LED","IST","HKG","PEE","UFA",
|
||||
"OVB","CEK","CAI","SVX","EVN","DEL","MSQ",
|
||||
"KJA","KUF","NQZ","OMS","ALA","SHA","VOG",
|
||||
"BAK","BKK","BJS","MOW","GOJ","VVO","KHV"
|
||||
]);
|
||||
|
||||
private _population500k = new Set<string>([
|
||||
"TJM","TOF","SKD","ULY","PEZ","MCX","KEJ",
|
||||
"IJK","REN","ASF","NOZ","BAX","RTW","DOH",
|
||||
"AUH","SSH","TAS","CIT","SGN","CAN","HRB",
|
||||
"VRA","KGF",
|
||||
]);
|
||||
|
||||
private _population100k = new Set<string>([
|
||||
"MMK","GDX","SGC","DYR","ESL","OSS","KVD","KVK","GUW","SCW",
|
||||
"SCO","NJC","NBC","UGC","UUD","YKS","MJZ","SKX","STW","KSN",
|
||||
"KGD","HMA","HTA","RGK","GRV","ABA","PKC","NAL","MQF","MRV",
|
||||
"OSW","ARH","UUS","NUX","BHK","PYJ","CSY","BQS","DLM"
|
||||
]);
|
||||
|
||||
private _popularResorts = new Set<string>([
|
||||
"AER","AYT","BJV","CMB","DPS","DXB","GOI",
|
||||
"HAV","HKT","HRG","MLE","NHA","SEZ","SYX","IKT"
|
||||
])
|
||||
|
||||
get population1kk(): Set<string> { return this._population1kk; }
|
||||
get population500k(): Set<string> { return this._population500k; }
|
||||
get population100k(): Set<string> { return this._population100k; }
|
||||
get popularResorts(): Set<string> { return this._popularResorts; }
|
||||
|
||||
zoomLevel(code: string): number
|
||||
{
|
||||
if (this._population1kk.has(code) || this._popularResorts.has(code))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (this._population500k.has(code))
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
|
||||
if (this._population100k.has(code))
|
||||
{
|
||||
return 6;
|
||||
}
|
||||
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { HttpParams } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { EndpointService } from '@app/shared/services/api/endpoint.service';
|
||||
import { ApiFormatterService } from '@app/shared/services/api/formatter.service';
|
||||
import { ApiService } from '@shared/services/api/api.service';
|
||||
import { IStringValues } from '@typings/common/util';
|
||||
import { IDestinationsResponse, IDaysResponse } from '@typings/responses';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
|
||||
export type IDestinationsRequestParams = {
|
||||
departure: string;
|
||||
arrival?: string;
|
||||
dateFrom: Date;
|
||||
dateTo?: Date;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
connections?: number;
|
||||
};
|
||||
|
||||
type IDestinationsHttpParams = IStringValues<IDestinationsRequestParams>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FlightsMapApiService {
|
||||
|
||||
constructor(private apiService: ApiService,
|
||||
private apiFormatter: ApiFormatterService,
|
||||
private endpointService: EndpointService) { }
|
||||
|
||||
|
||||
getDestinations(requestParams: IDestinationsRequestParams): Observable<IDestinationsResponse>{
|
||||
const url = this.endpointService.buildLocalizedURL('destinations', '1');
|
||||
|
||||
const httpParams: IDestinationsHttpParams = {
|
||||
...requestParams,
|
||||
dateFrom: this.apiFormatter.formatDateOnly(requestParams.dateFrom),
|
||||
dateTo: this.apiFormatter.formatDateOnly(requestParams.dateTo ?? this.addDays(requestParams.dateFrom, 1)),
|
||||
connections: String(requestParams.connections ?? 0),
|
||||
};
|
||||
|
||||
let params = new HttpParams({
|
||||
fromObject: httpParams,
|
||||
});
|
||||
|
||||
return this.apiService.cacheFirst$<IDestinationsResponse>(url, params);
|
||||
}
|
||||
|
||||
public getFlightDaysByRoute(date: Date, src: string, dest: string, connections: boolean): Observable<IDaysResponse> {
|
||||
const formatDate = this.apiFormatter.formatDateOnly(date);
|
||||
|
||||
let param = '';
|
||||
|
||||
if(src && !dest)
|
||||
{
|
||||
param = `departure/${src}`
|
||||
}
|
||||
else if(src && dest)
|
||||
{
|
||||
param = connections ? `connections/${src}-${dest}-1` : `route/${src}-${dest}`;
|
||||
}
|
||||
else
|
||||
{
|
||||
return of<null>();
|
||||
}
|
||||
|
||||
const url = this.endpointService.buildLocalizedURL(
|
||||
`days/${formatDate}/200/${param}/flights-map/`, //`days/${formatDate}/185/${param}/flights-map/` расширено до 200, чтобы при нахождении на сайте нескольких дней актуальность календаря
|
||||
'v1', // сохранялась по заранее запрошенным данным (не делать автообновление, возможно на будущую доработку по автообновлению)
|
||||
);
|
||||
console.log(url);
|
||||
const httpParams = new HttpParams({
|
||||
});
|
||||
|
||||
return this.apiService.cacheFirst$<IDaysResponse>(url, httpParams);
|
||||
}
|
||||
|
||||
private addDays(date: Date, days: number): Date {
|
||||
const newDate = new Date(date.getTime() + (1000 * 60 * 60 * 24 * days));
|
||||
|
||||
return newDate;
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<div class="filter-content">
|
||||
<div class="p-field">
|
||||
<label class="label--filter">{{
|
||||
'SHARED.FLIGHT_NUMBER' | translate
|
||||
}}</label>
|
||||
<tooltip *ngIf="validationService.flightNumberError">{{
|
||||
validationService.flightNumberError | translate
|
||||
}}</tooltip>
|
||||
|
||||
<div
|
||||
class="number-input-composite"
|
||||
[ngClass]="{
|
||||
'has-value': flightNumber,
|
||||
'has-error': validationService.flightNumberError
|
||||
}"
|
||||
>
|
||||
<div class="prefix">SU</div>
|
||||
<input
|
||||
pInputText
|
||||
type="text"
|
||||
class="input--filter input--flight-number ui-inputtext"
|
||||
[(ngModel)]="flightNumber"
|
||||
(change)="addZeros()"
|
||||
autocomplete="off"
|
||||
maxlength="5"
|
||||
placeholder="{{
|
||||
'SHARED.FLIGHT_NUMBER_PLACEHOLDER' | translate
|
||||
}}"
|
||||
data-testid="flight-number-input"
|
||||
/>
|
||||
<button
|
||||
pButton
|
||||
label=" "
|
||||
class="button-clear"
|
||||
(click)="clearInput()"
|
||||
data-testid="flight-number-clear-button"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<calendar-input
|
||||
label="SHARED.FLIGHT_DATE"
|
||||
[(ngModel)]="date"
|
||||
[error]="validationService.dateError"
|
||||
[minDate]="minDate"
|
||||
[maxDate]="maxDate"
|
||||
[disabledDates]="disabledDates"
|
||||
data-testid="flight-number-calendar"
|
||||
>
|
||||
</calendar-input>
|
||||
</div>
|
||||
|
||||
<div class="filter-button">
|
||||
<button
|
||||
class="search-button color blue-light"
|
||||
pButton
|
||||
type="button"
|
||||
label="{{ 'SHARED.SEARCH' | translate }}"
|
||||
(click)="search()"
|
||||
data-testid="flight-number-search-button"
|
||||
></button>
|
||||
</div>
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { OnlineBoardFlightNumberFilterValidationService } from '@online-board/components/filter/components/flight-number-filter/services/online-board-flight-number-filter-validation.service';
|
||||
import { IOnlineBoardRouteData } from '@online-board/types';
|
||||
import { getMockPipe } from '@shared/pipes/pipe.mock';
|
||||
import { OnlineBoardFiltersStateService } from '@shared/services/filters/online-board-filters-state.service';
|
||||
import { UserLocationService } from '@shared/services/user-location/user-location.service';
|
||||
import { getUserLocationServiceMock } from '@shared/services/user-location/user-location.service.mock';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { OnlineBoardFlightNumberFilterComponent } from './flight-number-filter.component';
|
||||
|
||||
describe('FlightNumberFilterComponent', () => {
|
||||
let component: OnlineBoardFlightNumberFilterComponent;
|
||||
let fixture: ComponentFixture<OnlineBoardFlightNumberFilterComponent>;
|
||||
let locationService;
|
||||
const data: IOnlineBoardRouteData = {
|
||||
urlParams: {},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
locationService = getUserLocationServiceMock('MOW');
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
OnlineBoardFlightNumberFilterComponent,
|
||||
getMockPipe('translate'),
|
||||
],
|
||||
providers: [
|
||||
OnlineBoardFlightNumberFilterValidationService,
|
||||
OnlineBoardFiltersStateService,
|
||||
{
|
||||
provide: UserLocationService,
|
||||
useValue: locationService,
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
data: new BehaviorSubject(data),
|
||||
},
|
||||
},
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(
|
||||
OnlineBoardFlightNumberFilterComponent,
|
||||
);
|
||||
component = fixture.componentInstance;
|
||||
data.urlParams = undefined;
|
||||
|
||||
jasmine.clock().install();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
it('should add zeros to flight number', () => {
|
||||
component.flightNumber = '1';
|
||||
component.addZeros();
|
||||
expect(component.flightNumber).toBe('0001');
|
||||
});
|
||||
|
||||
it('should clear flight number', () => {
|
||||
component.flightNumber = '0001';
|
||||
component.clearInput();
|
||||
expect(component.flightNumber).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should emit valid params', () => {
|
||||
const flightNumber = '0001';
|
||||
const date = new Date(2022, 5, 6);
|
||||
|
||||
component.onSearch.emit = jasmine.createSpy('onSearch.emit');
|
||||
component.flightNumber = flightNumber;
|
||||
component.date = date;
|
||||
component.search();
|
||||
|
||||
expect(component.onSearch.emit).toHaveBeenCalledWith({
|
||||
flightNumber,
|
||||
carrier: 'SU',
|
||||
date,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit invalid params', () => {
|
||||
const flightNumber = '0001';
|
||||
const date = new Date('invalid');
|
||||
|
||||
component.onSearch.emit = jasmine.createSpy('onSearch.emit');
|
||||
component.flightNumber = flightNumber;
|
||||
component.date = date;
|
||||
component.search();
|
||||
|
||||
expect(component.onSearch.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set state on init', () => {
|
||||
data.urlParams = {
|
||||
flightNumber: {
|
||||
flightNumber: '0001',
|
||||
carrier: 'SU',
|
||||
date: new Date(2022, 5, 6),
|
||||
},
|
||||
};
|
||||
|
||||
expect(component.flightNumber).toBe(undefined);
|
||||
expect(component.date).toBe(undefined);
|
||||
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.flightNumber).toBe('0001');
|
||||
expect(component.date).toEqual(new Date(2022, 5, 6));
|
||||
});
|
||||
|
||||
it('should not set state on init if urlParams were not provided', () => {
|
||||
data.urlParams = undefined;
|
||||
const setStateMock = jasmine.createSpy('setState');
|
||||
(component as any).setState = setStateMock;
|
||||
component.ngOnInit();
|
||||
|
||||
expect(setStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not set state on init if there was no flightNumber field in urlParams', () => {
|
||||
data.urlParams = {};
|
||||
const setStateMock = jasmine.createSpy('setState');
|
||||
(component as any).setState = setStateMock;
|
||||
component.ngOnInit();
|
||||
|
||||
expect(setStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set default state on init if geolocation is permitted', () => {
|
||||
const mockedToday = new Date(2022, 4, 28);
|
||||
expect(component.flightNumber).toBe(undefined);
|
||||
expect(component.date).toBe(undefined);
|
||||
|
||||
jasmine.clock().mockDate(mockedToday);
|
||||
locationService.locate();
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.flightNumber).toBe(undefined);
|
||||
expect(component.date.toDateString()).toBe(mockedToday.toDateString());
|
||||
});
|
||||
|
||||
it('should not set default state on init if geolocation is not permitted', () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.flightNumber).toBe(undefined);
|
||||
expect(component.date).toBe(undefined);
|
||||
});
|
||||
});
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { OnlineBoardFlightNumberFilterValidationService } from '@online-board/components/filter/components/flight-number-filter/services/online-board-flight-number-filter-validation.service';
|
||||
import { IOnlineBoardRouteData } from '@online-board/types';
|
||||
import { OnlineBoardFiltersStateService } from '@shared/services/filters/online-board-filters-state.service';
|
||||
import { UserLocationService } from '@shared/services/user-location/user-location.service';
|
||||
import { IParsedFlightId } from '@typings/flight/flight-id';
|
||||
import { OnlineBoardApiService } from '@online-board/services/api.service';
|
||||
import { StateService } from '@shared/services/state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'online-board-flight-number-filter',
|
||||
templateUrl: './flight-number-filter.component.html',
|
||||
providers: [OnlineBoardFlightNumberFilterValidationService],
|
||||
})
|
||||
export class OnlineBoardFlightNumberFilterComponent {
|
||||
@Input() minDate: Date;
|
||||
@Input() maxDate: Date;
|
||||
disabledDates: Date[];
|
||||
|
||||
@Output() onSearch = new EventEmitter<IParsedFlightId>();
|
||||
|
||||
constructor(
|
||||
public validationService: OnlineBoardFlightNumberFilterValidationService,
|
||||
private apiService: OnlineBoardApiService,
|
||||
private route: ActivatedRoute,
|
||||
private locationService: UserLocationService,
|
||||
private state: OnlineBoardFiltersStateService,
|
||||
private stateService: StateService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.route.data.subscribe((data: IOnlineBoardRouteData) => {
|
||||
// There is no urlParams.flightNumber field
|
||||
// in data object if flight number search page
|
||||
// isn't opened
|
||||
if (!data.urlParams?.flightNumber) {
|
||||
return;
|
||||
}
|
||||
this.setState(data.urlParams.flightNumber);
|
||||
|
||||
});
|
||||
this.locationService.location.subscribe((location) => {
|
||||
// set default state only if user permitted geo position
|
||||
// search and filter isn't filled
|
||||
if (location && !this.flightNumber) {
|
||||
this.setDefaultState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
}
|
||||
|
||||
get flightNumber() {
|
||||
return this.state.flightNumber;
|
||||
}
|
||||
|
||||
set flightNumber(flightNumber: string) {
|
||||
this.state.flightNumber = flightNumber;
|
||||
if (flightNumber.length >= 4 && /^\d\d\d\d/.test(flightNumber)) {
|
||||
var date = new Date();
|
||||
date.setUTCHours(0,0,0,0);
|
||||
date.setDate(date.getDate() - 1);
|
||||
this.apiService
|
||||
.getFlightDaysByNumber(date, flightNumber)
|
||||
.then((res) => {
|
||||
this.disabledDates = new Array();
|
||||
for(var i=0;i<res.days.length;i++) {
|
||||
if (res.days[i] == '0') {
|
||||
this.disabledDates.push(new Date(date));
|
||||
}
|
||||
date.setDate(date.getDate() + 1);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
});
|
||||
} else {
|
||||
this.disabledDates = new Array();
|
||||
}
|
||||
}
|
||||
|
||||
get suffix() {
|
||||
return this.state.suffix;
|
||||
}
|
||||
|
||||
set suffix(suffix: string) {
|
||||
this.state.suffix = suffix;
|
||||
}
|
||||
|
||||
get date() {
|
||||
return this.state.flightNumberDate;
|
||||
}
|
||||
|
||||
set date(date: Date) {
|
||||
this.state.flightNumberDate = date;
|
||||
}
|
||||
|
||||
addZeros() {
|
||||
this.flightNumber = this.flightNumber.padStart(4, '0').toUpperCase();
|
||||
if (this.flightNumber.slice(-1)[0] > '9') {
|
||||
this.flightNumber = this.flightNumber.padStart(5, '0');
|
||||
}
|
||||
}
|
||||
|
||||
clearInput() {
|
||||
this.flightNumber = undefined;
|
||||
}
|
||||
|
||||
async search() {
|
||||
const params = this.getSearchParams();
|
||||
const areParamsValid = this.validationService.validate(params);
|
||||
if (!areParamsValid) {
|
||||
return;
|
||||
}
|
||||
this.stateService.set("boardnumber", params);
|
||||
|
||||
this.onSearch.emit(params);
|
||||
}
|
||||
|
||||
private setState(params: IParsedFlightId) {
|
||||
// uncomment in the following line if want to use 123D instead of 0123D
|
||||
this.flightNumber = (params.flightNumber+params.suffix).slice(/*+params.flightNumber > 999 ?*/ -5 /*: -4*/);
|
||||
this.date = params.date;
|
||||
this.suffix = params.suffix;
|
||||
}
|
||||
|
||||
private setDefaultState() {
|
||||
this.date = new Date();
|
||||
}
|
||||
|
||||
private getSearchParams(): IParsedFlightId {
|
||||
const suffix = this.flightNumber.length > 0 && this.flightNumber.slice(-1)[0] > '9' ? this.flightNumber.slice(-1) : "";
|
||||
const flightNumber = (suffix.length > 0) ? this.flightNumber.slice(0,-1) : this.flightNumber;
|
||||
|
||||
return {
|
||||
flightNumber: flightNumber,
|
||||
date: this.date,
|
||||
carrier: 'SU',
|
||||
suffix: suffix,
|
||||
};
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { OnlineBoardFlightNumberFilterValidationService } from '@online-board/components/filter/components/flight-number-filter/services/online-board-flight-number-filter-validation.service';
|
||||
import { IParsedFlightId } from '@typings/flight/flight-id';
|
||||
|
||||
describe('OnlineBoardFlightNumberFilterValidationService', () => {
|
||||
let service: OnlineBoardFlightNumberFilterValidationService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [OnlineBoardFlightNumberFilterValidationService],
|
||||
});
|
||||
|
||||
service = TestBed.inject(
|
||||
OnlineBoardFlightNumberFilterValidationService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false and set flightNumberError because flight number is not provided', () => {
|
||||
const params: Partial<IParsedFlightId> = {};
|
||||
|
||||
expect(service.validate(params)).toBe(false);
|
||||
expect(service.flightNumberError).toBe(
|
||||
'BOARD.FLIGHT_NUMBER-ERROR-EMPTY',
|
||||
);
|
||||
expect(service.dateError).toBe(null);
|
||||
});
|
||||
|
||||
it('should return false because flight number does not meet pattern', () => {
|
||||
const params: Partial<IParsedFlightId> = {
|
||||
flightNumber: 'invalid',
|
||||
};
|
||||
|
||||
expect(service.validate(params)).toBe(false);
|
||||
expect(service.flightNumberError).toBe(
|
||||
'BOARD.FLIGHT_NUMBER-ERROR-ONLY-NUMBER',
|
||||
);
|
||||
expect(service.dateError).toBe(null);
|
||||
});
|
||||
|
||||
it('should return false because flight number`s length is more than 4', () => {
|
||||
const params: Partial<IParsedFlightId> = {
|
||||
flightNumber: '11111',
|
||||
};
|
||||
|
||||
expect(service.validate(params)).toBe(false);
|
||||
expect(service.flightNumberError).toBe(
|
||||
'BOARD.FLIGHT_NUMBER-ERROR-ONLY-NUMBER',
|
||||
);
|
||||
expect(service.dateError).toBe(null);
|
||||
});
|
||||
|
||||
it('should return false because date is not provided', () => {
|
||||
const params: Partial<IParsedFlightId> = {
|
||||
flightNumber: '0001',
|
||||
};
|
||||
|
||||
expect(service.validate(params)).toBe(false);
|
||||
expect(service.flightNumberError).toBe(null);
|
||||
expect(service.dateError).toBe('SHARED.DATE_FORMAT-WRONG');
|
||||
});
|
||||
|
||||
it('should return false because date is invalid', () => {
|
||||
const params: Partial<IParsedFlightId> = {
|
||||
flightNumber: '0001',
|
||||
date: new Date('invalid'),
|
||||
};
|
||||
|
||||
expect(service.validate(params)).toBe(false);
|
||||
expect(service.flightNumberError).toBe(null);
|
||||
expect(service.dateError).toBe('SHARED.DATE_FORMAT-WRONG');
|
||||
});
|
||||
|
||||
it('should return true because all params are valid', () => {
|
||||
const params: Partial<IParsedFlightId> = {
|
||||
flightNumber: '0001',
|
||||
date: new Date(2022, 5, 6),
|
||||
};
|
||||
|
||||
expect(service.validate(params)).toBe(true);
|
||||
expect(service.flightNumberError).toBe(null);
|
||||
expect(service.dateError).toBe(null);
|
||||
});
|
||||
|
||||
it('should clear errors before validation', () => {
|
||||
const params: Partial<IParsedFlightId> = {
|
||||
flightNumber: '0001',
|
||||
date: new Date('invalid'),
|
||||
};
|
||||
|
||||
expect(service.validate(params)).toBe(false);
|
||||
expect(service.flightNumberError).toBe(null);
|
||||
expect(service.dateError).toBe('SHARED.DATE_FORMAT-WRONG');
|
||||
|
||||
params.date = new Date(2022, 5, 6);
|
||||
expect(service.validate(params)).toBe(true);
|
||||
expect(service.flightNumberError).toBe(null);
|
||||
expect(service.dateError).toBe(null);
|
||||
});
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { IParsedFlightId } from '@typings/flight/flight-id';
|
||||
import * as moment from 'moment';
|
||||
|
||||
@Injectable()
|
||||
export class OnlineBoardFlightNumberFilterValidationService {
|
||||
flightNumberError: string | null = null;
|
||||
dateError: string | null = null;
|
||||
|
||||
validate(params: Partial<IParsedFlightId>) {
|
||||
this.dateError = this.flightNumberError = null;
|
||||
|
||||
if (!params.flightNumber) {
|
||||
this.flightNumberError = 'BOARD.FLIGHT_NUMBER-ERROR-EMPTY';
|
||||
return false;
|
||||
}
|
||||
|
||||
const reg = new RegExp('^\\d\\d\\d\\d?[A-Za-z]?$');
|
||||
if (!reg.test(params.flightNumber) || params.flightNumber.length > 5) {
|
||||
this.flightNumberError = 'BOARD.FLIGHT_NUMBER-ERROR-ONLY-NUMBER';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!params.date || !moment(params.date).isValid()) {
|
||||
this.dateError = 'SHARED.DATE_FORMAT-WRONG';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<div class="filter-content">
|
||||
<city-autocomplete
|
||||
label="SHARED.DEPARTURE_CITY"
|
||||
[(ngModel)]="departure"
|
||||
[(error)]="validationService.departureError"
|
||||
[placeholder]="departurePlaceholder"
|
||||
data-testid="route-departure-city-input"
|
||||
></city-autocomplete>
|
||||
|
||||
<div class="change-container">
|
||||
<button
|
||||
class="button-change"
|
||||
pButton
|
||||
type="button"
|
||||
(click)="exchange()"
|
||||
>
|
||||
<svg class="svg--change-city">
|
||||
<use xlink:href="/assets/img/sprite.svg#changeCity" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<city-autocomplete
|
||||
label="SHARED.ARRIVAL_CITY"
|
||||
[(ngModel)]="arrival"
|
||||
[(error)]="validationService.arrivalError"
|
||||
[placeholder]="arrivalPlaceholder"
|
||||
data-testid="route-arrival-city-input"
|
||||
></city-autocomplete>
|
||||
|
||||
<calendar-input
|
||||
label="SHARED.FLIGHT_DATE"
|
||||
[(ngModel)]="date"
|
||||
[error]="validationService.dateError"
|
||||
[minDate]="minDate"
|
||||
[maxDate]="maxDate"
|
||||
[disabledDates]="disabledDates"
|
||||
data-testid="route-calendar-input"
|
||||
>
|
||||
</calendar-input>
|
||||
</div>
|
||||
|
||||
<time-selector
|
||||
[fullView]="false"
|
||||
[(ngModel)]="timeRange"
|
||||
label="{{ 'SHARED.FLIGHT_TIME' | translate }}"
|
||||
>
|
||||
</time-selector>
|
||||
|
||||
<div class="filter-button">
|
||||
<button
|
||||
class="search-button color blue-light"
|
||||
pButton
|
||||
type="button"
|
||||
label="{{ 'SHARED.SEARCH' | translate }}"
|
||||
(click)="search()"
|
||||
data-testid="route-search-button"
|
||||
></button>
|
||||
</div>
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
|
||||
import { getDictionariesServiceMock } from '@modules/components/page-filters/services/dictionaries-service.mock';
|
||||
import { OnlineBoardRouteFilterValidationService } from '@online-board/components/filter/components/route-filter/services/online-board-route-filter-validation.service';
|
||||
import { IOnlineBoardRouteData } from '@online-board/types';
|
||||
import { getMockPipe } from '@shared/pipes/pipe.mock';
|
||||
import { OnlineBoardFiltersStateService } from '@shared/services/filters/online-board-filters-state.service';
|
||||
import { UserLocationService } from '@shared/services/user-location/user-location.service';
|
||||
import { getUserLocationServiceMock } from '@shared/services/user-location/user-location.service.mock';
|
||||
import { StationCodeValidationService } from '@shared/services/validators/station-code.service';
|
||||
import { areSame } from '@utils/date';
|
||||
import MobileUtils from '@utils/mobile';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { OnlineBoardRouteFilterComponent } from './online-board-route-filter.component';
|
||||
|
||||
describe('OnlineBoardRouteFilterComponent', () => {
|
||||
let component: OnlineBoardRouteFilterComponent;
|
||||
let fixture: ComponentFixture<OnlineBoardRouteFilterComponent>;
|
||||
let locationService: UserLocationService;
|
||||
const data: IOnlineBoardRouteData = {
|
||||
urlParams: {},
|
||||
};
|
||||
let isMobileMock;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
OnlineBoardRouteFilterComponent,
|
||||
getMockPipe('translate'),
|
||||
],
|
||||
providers: [
|
||||
OnlineBoardRouteFilterValidationService,
|
||||
StationCodeValidationService,
|
||||
OnlineBoardFiltersStateService,
|
||||
{
|
||||
provide: DictionariesService,
|
||||
useValue: getDictionariesServiceMock(),
|
||||
},
|
||||
{
|
||||
provide: UserLocationService,
|
||||
useValue: getUserLocationServiceMock('MOW'),
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
data: new BehaviorSubject(data),
|
||||
},
|
||||
},
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(OnlineBoardRouteFilterComponent);
|
||||
component = fixture.componentInstance;
|
||||
locationService = TestBed.inject(UserLocationService);
|
||||
|
||||
isMobileMock = spyOn(MobileUtils, 'isMobile');
|
||||
data.urlParams.route = undefined;
|
||||
jasmine.clock().install();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
it('should return appropriate departure placeholder', () => {
|
||||
expect(component.departurePlaceholder).toBe('SHARED.CITY_PLACEHOLDER');
|
||||
component.arrival = 'MOW';
|
||||
expect(component.departurePlaceholder).toBe('SHARED.ALL_DIRECTIONS');
|
||||
});
|
||||
|
||||
it('should return appropriate arrival placeholder', () => {
|
||||
expect(component.arrivalPlaceholder).toBe('SHARED.CITY_PLACEHOLDER');
|
||||
component.departure = 'MOW';
|
||||
expect(component.arrivalPlaceholder).toBe('SHARED.ALL_DIRECTIONS');
|
||||
});
|
||||
|
||||
it('should exchange stations', () => {
|
||||
component.arrival = 'MOW';
|
||||
component.departure = 'GDX';
|
||||
component.exchange();
|
||||
|
||||
expect(component.arrival).toBe('GDX');
|
||||
expect(component.departure).toBe('MOW');
|
||||
});
|
||||
|
||||
it('should not emit invalid params', () => {
|
||||
component.onSearch.emit = jasmine.createSpy('onSearch.emit');
|
||||
component.arrival = 'AAA';
|
||||
|
||||
component.search();
|
||||
expect(component.onSearch.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit valid params', async () => {
|
||||
const date = new Date(2022, 4, 28);
|
||||
component.onSearch.emit = jasmine.createSpy('onSearch.emit');
|
||||
component.arrival = 'MOW';
|
||||
component.departure = 'GDX';
|
||||
component.date = date;
|
||||
|
||||
await component.search();
|
||||
expect(component.onSearch.emit).toHaveBeenCalledWith({
|
||||
arrival: 'MOW',
|
||||
departure: 'GDX',
|
||||
date,
|
||||
timeFrom: undefined,
|
||||
timeTo: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit valid params with time range', async () => {
|
||||
const date = new Date(2022, 4, 28);
|
||||
component.onSearch.emit = jasmine.createSpy('onSearch.emit');
|
||||
component.arrival = 'MOW';
|
||||
component.departure = 'GDX';
|
||||
component.date = date;
|
||||
component.timeRange = {
|
||||
timeFrom: '1300',
|
||||
timeTo: '1700',
|
||||
};
|
||||
|
||||
await component.search();
|
||||
expect(component.onSearch.emit).toHaveBeenCalledWith({
|
||||
arrival: 'MOW',
|
||||
departure: 'GDX',
|
||||
date,
|
||||
timeFrom: '1300',
|
||||
timeTo: '1700',
|
||||
});
|
||||
});
|
||||
|
||||
it('should set state on init', () => {
|
||||
const date = new Date(2022, 4, 28);
|
||||
data.urlParams.route = {
|
||||
arrival: 'MOW',
|
||||
date,
|
||||
};
|
||||
|
||||
component.ngOnInit();
|
||||
expect(component.arrival).toBe('MOW');
|
||||
expect(component.date).toEqual(date);
|
||||
expect(component.timeRange).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should set time range on init', () => {
|
||||
data.urlParams.route = {
|
||||
arrival: 'MOW',
|
||||
date: new Date(2022, 4, 28),
|
||||
timeFrom: '1300',
|
||||
timeTo: '1700',
|
||||
};
|
||||
|
||||
component.ngOnInit();
|
||||
expect(component.timeRange.timeFrom).toBe('1300');
|
||||
expect(component.timeRange.timeTo).toBe('1700');
|
||||
});
|
||||
|
||||
it('should set time range on init only if provided both time range fields', () => {
|
||||
data.urlParams.route = {
|
||||
arrival: 'MOW',
|
||||
date: new Date(2022, 4, 28),
|
||||
timeFrom: '1300',
|
||||
};
|
||||
|
||||
component.ngOnInit();
|
||||
expect(component.timeRange).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should not set state on init if url params were not provided', () => {
|
||||
data.urlParams = undefined;
|
||||
const setStateMock = jasmine.createSpy('setState');
|
||||
(component as any).setState = setStateMock;
|
||||
|
||||
component.ngOnInit();
|
||||
expect(setStateMock).not.toHaveBeenCalled();
|
||||
data.urlParams = {};
|
||||
});
|
||||
|
||||
it('should not set state on init if where if no route field in url params', () => {
|
||||
const setStateMock = jasmine.createSpy('setState');
|
||||
(component as any).setState = setStateMock;
|
||||
|
||||
component.ngOnInit();
|
||||
expect(setStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set default state on init if geolocation is permitted and device is not mobile', () => {
|
||||
isMobileMock.and.returnValue(false);
|
||||
const mockedToday = new Date(2022, 5, 6);
|
||||
jasmine.clock().mockDate(mockedToday);
|
||||
|
||||
locationService.locate();
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.departure).toBe('MOW');
|
||||
expect(areSame(component.date, mockedToday)).toBe(true);
|
||||
expect(component.timeRange).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should set default time range on init if geolocation is permitted and device is mobile', () => {
|
||||
isMobileMock.and.returnValue(true);
|
||||
const mockedToday = new Date(2022, 5, 6, 14, 21);
|
||||
jasmine.clock().mockDate(mockedToday);
|
||||
|
||||
locationService.locate();
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.timeRange?.timeFrom).toBe('1300');
|
||||
expect(component.timeRange?.timeTo).toBe('1700');
|
||||
});
|
||||
|
||||
it('should not set default state on init if geolocation is not permitted', () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.departure).toBe(undefined);
|
||||
expect(component.date).toBe(undefined);
|
||||
});
|
||||
});
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { OnlineBoardRouteFilterValidationService } from '@online-board/components/filter/components/route-filter/services/online-board-route-filter-validation.service';
|
||||
import { IOnlineBoardRoutePageUrlParams } from '@online-board/services/url/types';
|
||||
import { IOnlineBoardRouteData } from '@online-board/types';
|
||||
import { OnlineBoardFiltersStateService } from '@shared/services/filters/online-board-filters-state.service';
|
||||
import { UserLocationService } from '@shared/services/user-location/user-location.service';
|
||||
import { IUrlTimeRange } from '@typings/common/url';
|
||||
import MobileUtils from '@utils/mobile';
|
||||
import { getDefaultTimeRangeOnMobile } from '@utils/time-range';
|
||||
import { OnlineBoardApiService } from '@online-board/services/api.service';
|
||||
import { StateService } from '@shared/services/state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'online-board-route-filter',
|
||||
templateUrl: './online-board-route-filter.component.html',
|
||||
providers: [OnlineBoardRouteFilterValidationService],
|
||||
})
|
||||
export class OnlineBoardRouteFilterComponent implements OnInit {
|
||||
@Input() minDate: Date;
|
||||
@Input() maxDate: Date;
|
||||
disabledDates: Date[];
|
||||
|
||||
@Output() onSearch = new EventEmitter<IOnlineBoardRoutePageUrlParams>();
|
||||
|
||||
constructor(
|
||||
public validationService: OnlineBoardRouteFilterValidationService,
|
||||
private apiService: OnlineBoardApiService,
|
||||
private route: ActivatedRoute,
|
||||
private locationService: UserLocationService,
|
||||
private state: OnlineBoardFiltersStateService,
|
||||
private stateService: StateService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.route.data.subscribe((data: IOnlineBoardRouteData) => {
|
||||
if (data.urlParams?.route) {
|
||||
this.setState(data.urlParams.route);
|
||||
}
|
||||
});
|
||||
|
||||
this.locationService.location.subscribe((location) => {
|
||||
// set default state only if user permitted geo position
|
||||
// search and filter isn't filled
|
||||
if (location && !this.departure && !this.arrival) {
|
||||
this.setDefaultState(location);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get date() {
|
||||
return this.state.routeDate;
|
||||
}
|
||||
|
||||
set date(date: Date) {
|
||||
this.state.routeDate = date;
|
||||
}
|
||||
|
||||
get arrival() {
|
||||
return this.state.arrival;
|
||||
}
|
||||
|
||||
set arrival(arrival: string) {
|
||||
this.state.arrival = arrival;
|
||||
this.updateCalendar();
|
||||
}
|
||||
|
||||
get departure() {
|
||||
return this.state.departure;
|
||||
}
|
||||
|
||||
set departure(departure: string) {
|
||||
this.state.departure = departure;
|
||||
this.updateCalendar();
|
||||
}
|
||||
|
||||
get timeRange() {
|
||||
return this.state.timeRange;
|
||||
}
|
||||
|
||||
set timeRange(timeRange: IUrlTimeRange) {
|
||||
this.state.timeRange = timeRange;
|
||||
}
|
||||
|
||||
get departurePlaceholder() {
|
||||
return this.arrival
|
||||
? 'SHARED.ALL_DIRECTIONS'
|
||||
: 'SHARED.CITY_PLACEHOLDER';
|
||||
}
|
||||
|
||||
get arrivalPlaceholder() {
|
||||
return this.departure
|
||||
? 'SHARED.ALL_DIRECTIONS'
|
||||
: 'SHARED.CITY_PLACEHOLDER';
|
||||
}
|
||||
|
||||
async updateCalendar() {
|
||||
const isDepartureValid = await this.validationService.validateCode(this.departure);
|
||||
const isArrivalValid = await this.validationService.validateCode(this.arrival);
|
||||
|
||||
if (isDepartureValid || isArrivalValid) {
|
||||
var date = new Date();
|
||||
date.setUTCHours(0,0,0,0);
|
||||
date.setDate(date.getDate() - 1);
|
||||
this.apiService
|
||||
.getFlightDaysByRoute(date, isDepartureValid ? this.departure: undefined, isArrivalValid ? this.arrival: undefined)
|
||||
.then((res) => {
|
||||
this.disabledDates = new Array();
|
||||
for(var i=0;i<res.days.length;i++) {
|
||||
if (res.days[i] == '0') {
|
||||
this.disabledDates.push(new Date(date));
|
||||
}
|
||||
date.setDate(date.getDate() + 1);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
});
|
||||
} else {
|
||||
this.disabledDates = new Array();
|
||||
}
|
||||
}
|
||||
|
||||
exchange() {
|
||||
[this.departure, this.arrival] = [this.arrival, this.departure];
|
||||
}
|
||||
|
||||
async search() {
|
||||
const params = this.getSearchParams();
|
||||
const areParamsValid = await this.validationService.validate(params);
|
||||
if (!areParamsValid) {
|
||||
return;
|
||||
}
|
||||
this.stateService.set("boardroute", params);
|
||||
|
||||
this.onSearch.emit(params);
|
||||
}
|
||||
|
||||
private setState(params: IOnlineBoardRoutePageUrlParams) {
|
||||
this.departure = params.departure;
|
||||
this.arrival = params.arrival;
|
||||
this.date = params.date;
|
||||
|
||||
if (params.timeFrom && params.timeTo) {
|
||||
this.timeRange = {
|
||||
timeFrom: params.timeFrom,
|
||||
timeTo: params.timeTo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private setDefaultState(userLocation: string) {
|
||||
this.departure = userLocation;
|
||||
this.date = new Date();
|
||||
|
||||
if (MobileUtils.isMobile()) {
|
||||
this.timeRange = getDefaultTimeRangeOnMobile();
|
||||
}
|
||||
this.search();
|
||||
}
|
||||
|
||||
private getSearchParams(): IOnlineBoardRoutePageUrlParams {
|
||||
return {
|
||||
arrival: this.arrival,
|
||||
departure: this.departure,
|
||||
date: this.date,
|
||||
timeFrom: this.timeRange?.timeFrom,
|
||||
timeTo: this.timeRange?.timeTo,
|
||||
};
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
|
||||
import { getDictionariesServiceMock } from '@modules/components/page-filters/services/dictionaries-service.mock';
|
||||
import { OnlineBoardRouteFilterValidationService } from '@online-board/components/filter/components/route-filter/services/online-board-route-filter-validation.service';
|
||||
import { IOnlineBoardRoutePageUrlParams } from '@online-board/services/url';
|
||||
import { StationCodeValidationService } from '@shared/services/validators/station-code.service';
|
||||
|
||||
describe('OnlineBoardRouteFilterValidationService', () => {
|
||||
let service: OnlineBoardRouteFilterValidationService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
OnlineBoardRouteFilterValidationService,
|
||||
StationCodeValidationService,
|
||||
{
|
||||
provide: DictionariesService,
|
||||
useValue: getDictionariesServiceMock(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(OnlineBoardRouteFilterValidationService);
|
||||
});
|
||||
|
||||
it('should return false because departure and arrival are not provided', async () => {
|
||||
const params: Partial<IOnlineBoardRoutePageUrlParams> = {};
|
||||
const result = await service.validate(params);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(service.departureError).toBe('SHARED.DEPARTURE-CITY-ERROR');
|
||||
expect(service.arrivalError).toBe('SHARED.ARRIVAL-CITY-ERROR');
|
||||
});
|
||||
|
||||
it('should return false because departure is not valid', async () => {
|
||||
const params: Partial<IOnlineBoardRoutePageUrlParams> = {
|
||||
departure: 'AAA',
|
||||
};
|
||||
const result = await service.validate(params);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(service.departureError).toBe('SHARED.DEPARTURE-CITY-ERROR');
|
||||
expect(service.arrivalError).toBe(null);
|
||||
});
|
||||
|
||||
it('should return false because arrival is not valid', async () => {
|
||||
const params: Partial<IOnlineBoardRoutePageUrlParams> = {
|
||||
arrival: 'AAA',
|
||||
};
|
||||
const result = await service.validate(params);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(service.departureError).toBe(null);
|
||||
expect(service.arrivalError).toBe('SHARED.ARRIVAL-CITY-ERROR');
|
||||
});
|
||||
|
||||
it('should return false because arrival equals to departure', async () => {
|
||||
const params: Partial<IOnlineBoardRoutePageUrlParams> = {
|
||||
arrival: 'MOW',
|
||||
departure: 'MOW',
|
||||
};
|
||||
const result = await service.validate(params);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(service.departureError).toBe(
|
||||
'SHARED.ARRIVAL-EQUALS-DEPARTURE-ERROR',
|
||||
);
|
||||
expect(service.arrivalError).toBe(null);
|
||||
});
|
||||
|
||||
it('should return false because date is not provided', async () => {
|
||||
const params: Partial<IOnlineBoardRoutePageUrlParams> = {
|
||||
arrival: 'MOW',
|
||||
};
|
||||
const result = await service.validate(params);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(service.departureError).toBe(null);
|
||||
expect(service.arrivalError).toBe(null);
|
||||
expect(service.dateError).toBe('SHARED.DATE_FORMAT-WRONG');
|
||||
});
|
||||
|
||||
it('should return false because date is invalid', async () => {
|
||||
const params: Partial<IOnlineBoardRoutePageUrlParams> = {
|
||||
arrival: 'MOW',
|
||||
date: new Date('invalid'),
|
||||
};
|
||||
const result = await service.validate(params);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(service.departureError).toBe(null);
|
||||
expect(service.arrivalError).toBe(null);
|
||||
expect(service.dateError).toBe('SHARED.DATE_FORMAT-WRONG');
|
||||
});
|
||||
|
||||
it('should return true because all params are valid', async () => {
|
||||
function assertTrue(result: boolean) {
|
||||
expect(result).toBe(true);
|
||||
expect(service.departureError).toBe(null);
|
||||
expect(service.arrivalError).toBe(null);
|
||||
expect(service.dateError).toBe(null);
|
||||
}
|
||||
const date = new Date(2022, 4, 28);
|
||||
|
||||
const arrivalParams: Partial<IOnlineBoardRoutePageUrlParams> = {
|
||||
arrival: 'MOW',
|
||||
date,
|
||||
};
|
||||
const arrivalResult = await service.validate(arrivalParams);
|
||||
assertTrue(arrivalResult);
|
||||
|
||||
const departureParams: Partial<IOnlineBoardRoutePageUrlParams> = {
|
||||
departure: 'MOW',
|
||||
date,
|
||||
};
|
||||
const departureResult = await service.validate(departureParams);
|
||||
assertTrue(departureResult);
|
||||
|
||||
const routeParams: Partial<IOnlineBoardRoutePageUrlParams> = {
|
||||
departure: 'MOW',
|
||||
arrival: 'GDX',
|
||||
date,
|
||||
};
|
||||
const routeResult = await service.validate(routeParams);
|
||||
assertTrue(routeResult);
|
||||
});
|
||||
|
||||
it('should clear errors before validation', async () => {
|
||||
const invalidParams: Partial<IOnlineBoardRoutePageUrlParams> = {};
|
||||
const invalidResult = await service.validate(invalidParams);
|
||||
|
||||
expect(invalidResult).toBe(false);
|
||||
expect(service.departureError).toBe('SHARED.DEPARTURE-CITY-ERROR');
|
||||
expect(service.arrivalError).toBe('SHARED.ARRIVAL-CITY-ERROR');
|
||||
|
||||
const validParams: Partial<IOnlineBoardRoutePageUrlParams> = {
|
||||
arrival: 'MOW',
|
||||
date: new Date(2022, 4, 28),
|
||||
};
|
||||
const validResult = await service.validate(validParams);
|
||||
|
||||
expect(validResult).toBe(true);
|
||||
expect(service.departureError).toBe(null);
|
||||
expect(service.arrivalError).toBe(null);
|
||||
expect(service.dateError).toBe(null);
|
||||
});
|
||||
});
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { IOnlineBoardRoutePageUrlParams } from '@online-board/services/url';
|
||||
import { StationCodeValidationService } from '@shared/services/validators/station-code.service';
|
||||
import * as moment from 'moment';
|
||||
|
||||
@Injectable()
|
||||
export class OnlineBoardRouteFilterValidationService {
|
||||
dateError: string;
|
||||
departureError: string;
|
||||
arrivalError: string;
|
||||
|
||||
constructor(private stationValidator: StationCodeValidationService) {}
|
||||
|
||||
async validateCode(code: string) {
|
||||
return this.stationValidator.isStationCodeValid(code);
|
||||
}
|
||||
|
||||
async validate(params: Partial<IOnlineBoardRoutePageUrlParams>) {
|
||||
this.dateError = this.departureError = this.arrivalError = null;
|
||||
|
||||
if (!params.departure && !params.arrival) {
|
||||
this.departureError = 'SHARED.DEPARTURE-CITY-ERROR';
|
||||
this.arrivalError = 'SHARED.ARRIVAL-CITY-ERROR';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const isDepartureValid = params.departure
|
||||
? await this.stationValidator.isStationCodeValid(params.departure)
|
||||
: true;
|
||||
|
||||
if (!isDepartureValid) {
|
||||
this.departureError = 'SHARED.DEPARTURE-CITY-ERROR';
|
||||
return false;
|
||||
}
|
||||
|
||||
const isArrivalValid = params.arrival
|
||||
? await this.stationValidator.isStationCodeValid(params.arrival)
|
||||
: true;
|
||||
|
||||
if (!isArrivalValid) {
|
||||
this.arrivalError = 'SHARED.ARRIVAL-CITY-ERROR';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (params.departure === params.arrival) {
|
||||
this.departureError = 'SHARED.ARRIVAL-EQUALS-DEPARTURE-ERROR';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!params.date || !moment(params.date).isValid()) {
|
||||
this.dateError = 'SHARED.DATE_FORMAT-WRONG';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<section class="frame">
|
||||
<p-accordion
|
||||
(onOpen)="handleOpen($event)"
|
||||
(onClose)="handleClose()"
|
||||
expandIcon=""
|
||||
collapseIcon=""
|
||||
>
|
||||
<p-accordionTab
|
||||
[selected]="isSelected('flight')"
|
||||
data-testid="flight-filter"
|
||||
>
|
||||
<p-header>
|
||||
{{ 'BOARD.FLIGHT_NUMBER' | translate }}
|
||||
<arrow-down-icon
|
||||
[color]="getIconColor('flight')"
|
||||
[rotated]="isSelected('flight')"
|
||||
></arrow-down-icon>
|
||||
</p-header>
|
||||
<online-board-flight-number-filter
|
||||
[minDate]="settings.boardMinDate"
|
||||
[maxDate]="settings.boardMaxDate"
|
||||
(onSearch)="handleFlightNumberSearch($event)"
|
||||
></online-board-flight-number-filter>
|
||||
</p-accordionTab>
|
||||
<p-accordionTab
|
||||
[selected]="isSelected('route')"
|
||||
data-testid="route-filter"
|
||||
>
|
||||
<p-header>
|
||||
{{ 'BOARD.ROUTE' | translate }}
|
||||
<arrow-down-icon
|
||||
[color]="getIconColor('route')"
|
||||
[rotated]="isSelected('route')"
|
||||
></arrow-down-icon>
|
||||
</p-header>
|
||||
<online-board-route-filter
|
||||
[minDate]="settings.boardMinDate"
|
||||
[maxDate]="settings.boardMaxDate"
|
||||
(onSearch)="handleRouteSearch($event)"
|
||||
></online-board-route-filter>
|
||||
</p-accordionTab>
|
||||
</p-accordion>
|
||||
</section>
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { OnlineBoardFilterComponent } from '@online-board/components/filter/online-board-filter.component';
|
||||
import { OnlineBoardFilterService } from '@online-board/services/filter/filter.service';
|
||||
import { getOnlineBoardFilterServiceMock } from '@online-board/services/filter/filter.service.mock';
|
||||
import { IOnlineBoardRoutePageUrlParams } from '@online-board/services/url';
|
||||
import { getMockPipe } from '@shared/pipes/pipe.mock';
|
||||
import { APP_SETTINGS } from '@shared/services';
|
||||
import { OnlineBoardFiltersStateService } from '@shared/services/filters/online-board-filters-state.service';
|
||||
import { IParsedFlightId } from '@typings/flight/flight-id';
|
||||
|
||||
describe('OnlineBoardFilterComponent', () => {
|
||||
let component: OnlineBoardFilterComponent;
|
||||
let fixture: ComponentFixture<OnlineBoardFilterComponent>;
|
||||
let filterService: OnlineBoardFilterService;
|
||||
let state: OnlineBoardFiltersStateService;
|
||||
|
||||
const startValidationDatesUpdatingIntervalMock = jasmine.createSpy(
|
||||
'startValidationDatesUpdatingInterval',
|
||||
);
|
||||
const stopValidationDatesUpdatingIntervalMock = jasmine.createSpy(
|
||||
'stopValidationDatesUpdatingInterval',
|
||||
);
|
||||
|
||||
const toFlightNumberPageMock = jasmine.createSpy('toFlightNumberPage');
|
||||
const toRoutePageMock = jasmine.createSpy('toRoutePage');
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
OnlineBoardFilterComponent,
|
||||
getMockPipe('translate'),
|
||||
],
|
||||
providers: [
|
||||
OnlineBoardFiltersStateService,
|
||||
{
|
||||
provide: APP_SETTINGS,
|
||||
useValue: {
|
||||
startValidationDatesUpdatingInterval:
|
||||
startValidationDatesUpdatingIntervalMock,
|
||||
stopValidationDatesUpdatingInterval:
|
||||
stopValidationDatesUpdatingIntervalMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: OnlineBoardFilterService,
|
||||
useValue: getOnlineBoardFilterServiceMock(
|
||||
toFlightNumberPageMock,
|
||||
toRoutePageMock,
|
||||
),
|
||||
},
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(OnlineBoardFilterComponent);
|
||||
component = fixture.componentInstance;
|
||||
filterService = TestBed.inject(OnlineBoardFilterService);
|
||||
state = TestBed.inject(OnlineBoardFiltersStateService);
|
||||
|
||||
startValidationDatesUpdatingIntervalMock.calls.reset();
|
||||
stopValidationDatesUpdatingIntervalMock.calls.reset();
|
||||
toFlightNumberPageMock.calls.reset();
|
||||
toRoutePageMock.calls.reset();
|
||||
});
|
||||
|
||||
it('Should start dates updating interval on init', () => {
|
||||
component.ngOnInit();
|
||||
expect(startValidationDatesUpdatingIntervalMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should stop dates updating interval on destroy', () => {
|
||||
component.ngOnDestroy();
|
||||
expect(stopValidationDatesUpdatingIntervalMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set selected tab', () => {
|
||||
expect(state.selectedTab).toBe('route');
|
||||
|
||||
component.handleOpen({ index: 0 });
|
||||
expect(state.selectedTab).toBe('flight');
|
||||
|
||||
component.handleOpen({ index: 1 });
|
||||
expect(state.selectedTab).toBe('route');
|
||||
});
|
||||
|
||||
it('should clear selected tab if selected tab was clicked', () => {
|
||||
expect(state.selectedTab).toBe('route');
|
||||
component.handleClose();
|
||||
|
||||
expect(state.selectedTab).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should return selected tab', () => {
|
||||
expect(component.selectedTab === state.selectedTab).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if tab is selected', () => {
|
||||
expect(component.selectedTab).toBe('route');
|
||||
|
||||
expect(component.isSelected('flight')).toBe(false);
|
||||
expect(component.isSelected('route')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return appropriate icon color', () => {
|
||||
expect(component.selectedTab).toBe('route');
|
||||
|
||||
expect(component.getIconColor('flight')).toBe('blue');
|
||||
expect(component.getIconColor('route')).toBe('gray');
|
||||
});
|
||||
|
||||
it('should call toFlightNumberPage', () => {
|
||||
const params: IParsedFlightId = {
|
||||
flightNumber: '0001',
|
||||
carrier: 'SU',
|
||||
date: new Date(2022, 4, 28),
|
||||
};
|
||||
|
||||
component.handleFlightNumberSearch(params);
|
||||
expect(filterService.toFlightNumberPage).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('should call toRoutePage', () => {
|
||||
const params: IOnlineBoardRoutePageUrlParams = {
|
||||
arrival: 'MOW',
|
||||
date: new Date(2022, 4, 28),
|
||||
};
|
||||
|
||||
component.handleRouteSearch(params);
|
||||
expect(filterService.toRoutePage).toHaveBeenCalledWith(params);
|
||||
});
|
||||
});
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
|
||||
import { OnlineBoardFilterService } from '@online-board/services/filter/filter.service';
|
||||
import { IOnlineBoardRoutePageUrlParams } from '@online-board/services/url';
|
||||
import { AppSettings } from '@shared/models-legacy';
|
||||
import { APP_SETTINGS } from '@shared/services';
|
||||
import {
|
||||
IOnlineBoardFilterSelectedTab,
|
||||
OnlineBoardFiltersStateService,
|
||||
} from '@shared/services/filters/online-board-filters-state.service';
|
||||
import { IArrowIconColor } from '@toolkit/icons/arrow-down/arrow-down-icon.component';
|
||||
import { IParsedFlightId } from '@typings/flight/flight-id';
|
||||
import { StateService } from '@shared/services/state.service';
|
||||
|
||||
type IAccordionToggleEvent = {
|
||||
index: number;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'online-board-filter',
|
||||
templateUrl: './online-board-filter.component.html',
|
||||
})
|
||||
export class OnlineBoardFilterComponent implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
@Inject(APP_SETTINGS) public settings: AppSettings,
|
||||
private filtersService: OnlineBoardFilterService,
|
||||
private state: OnlineBoardFiltersStateService,
|
||||
private stateService: StateService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
//this.settings.startValidationDatesUpdatingInterval();
|
||||
if (this.state.selectedTab == 'flight') {
|
||||
var saved_params = this.stateService.get("boardnumber");
|
||||
if (saved_params) {
|
||||
this.filtersService.toFlightNumberPage(saved_params);
|
||||
}
|
||||
}
|
||||
if (this.state.selectedTab == 'route') {
|
||||
var saved_params = this.stateService.get("boardroute");
|
||||
if (saved_params) {
|
||||
this.filtersService.toRoutePage(saved_params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
//this.settings.stopValidationDatesUpdatingInterval();
|
||||
}
|
||||
|
||||
get selectedTab() {
|
||||
return this.state.selectedTab;
|
||||
}
|
||||
|
||||
getIconColor(value: IOnlineBoardFilterSelectedTab): IArrowIconColor {
|
||||
return this.isSelected(value) ? 'gray' : 'blue';
|
||||
}
|
||||
|
||||
isSelected(value: IOnlineBoardFilterSelectedTab) {
|
||||
return this.selectedTab === value;
|
||||
}
|
||||
|
||||
handleOpen(event: IAccordionToggleEvent) {
|
||||
switch (event.index) {
|
||||
case 0: {
|
||||
this.state.selectedTab = 'flight';
|
||||
return;
|
||||
}
|
||||
case 1: {
|
||||
this.state.selectedTab = 'route';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
this.state.clearSelectedTab();
|
||||
}
|
||||
|
||||
handleFlightNumberSearch(params: IParsedFlightId) {
|
||||
return this.filtersService.toFlightNumberPage(params);
|
||||
}
|
||||
|
||||
handleRouteSearch(params: IOnlineBoardRoutePageUrlParams) {
|
||||
return this.filtersService.toRoutePage(params);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
import { ComponentsModule } from '@components/components.module';
|
||||
import { ToolkitModule } from '@toolkit/toolkit.module';
|
||||
import { OnlineBoardFilterComponent } from './online-board-filter.component';
|
||||
import { OnlineBoardFlightNumberFilterComponent } from './components/flight-number-filter/flight-number-filter.component';
|
||||
import { OnlineBoardRouteFilterComponent } from './components/route-filter/online-board-route-filter.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
OnlineBoardFilterComponent,
|
||||
OnlineBoardFlightNumberFilterComponent,
|
||||
OnlineBoardRouteFilterComponent,
|
||||
],
|
||||
exports: [OnlineBoardFilterComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ComponentsModule,
|
||||
AccordionModule,
|
||||
TranslateModule,
|
||||
ToolkitModule,
|
||||
],
|
||||
})
|
||||
export class OnlineBoardFilterModule {}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
|
||||
import { CanActivateArrivalSearch } from '@online-board/guards/can-activate-arrival-search';
|
||||
import { CanActivateDateParams } from '@online-board/guards/utils/can-activate-date-params';
|
||||
import { CanActivateRouteParams } from '@online-board/guards/utils/can-activate-route-params';
|
||||
import { CanActivateStations } from '@online-board/guards/utils/can-activate-stations';
|
||||
import { OnlineBoardDateValidationService } from '@online-board/services/date-validation.service';
|
||||
import { OnlineBoardNavigationService } from '@online-board/services/navigation.service';
|
||||
import { OnlineBoardUrlParserService } from '@online-board/services/url/url-parser.service';
|
||||
import { AppSettingsService } from '@shared/services';
|
||||
import { StationCodeValidationService } from '@shared/services/validators/station-code.service';
|
||||
import { TimeRangeValidationService } from '@shared/services/validators/time-range.service';
|
||||
|
||||
describe('CanActivateArrivalSearch', () => {
|
||||
let service: CanActivateArrivalSearch;
|
||||
|
||||
const boardMinDate = new Date(2022, 4, 23);
|
||||
const boardMaxDate = new Date(2022, 4, 29);
|
||||
const appSettingsService: Partial<AppSettingsService> = {
|
||||
getSettings: () => {
|
||||
return Promise.resolve({
|
||||
boardMinDate,
|
||||
boardMaxDate,
|
||||
}) as any;
|
||||
},
|
||||
};
|
||||
|
||||
const toNotFoundMock = jasmine.createSpy('toNotFound');
|
||||
const toStartPageMock = jasmine.createSpy('toStartPage');
|
||||
const navigationService: Partial<OnlineBoardNavigationService> = {
|
||||
toNotFound: toNotFoundMock,
|
||||
toStartPage: toStartPageMock,
|
||||
};
|
||||
|
||||
const dictionariesService: Partial<DictionariesService> = {
|
||||
ready$: Promise.resolve(),
|
||||
getCityOrAirport(code: string) {
|
||||
const map = new Map();
|
||||
map.set('MOW', true);
|
||||
map.set('LED', true);
|
||||
map.set('KUF', true);
|
||||
map.set('SVO', true);
|
||||
|
||||
return map.has(code) as any;
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
CanActivateArrivalSearch,
|
||||
CanActivateRouteParams,
|
||||
CanActivateStations,
|
||||
CanActivateDateParams,
|
||||
StationCodeValidationService,
|
||||
OnlineBoardDateValidationService,
|
||||
TimeRangeValidationService,
|
||||
OnlineBoardUrlParserService,
|
||||
{
|
||||
provide: AppSettingsService,
|
||||
useValue: appSettingsService,
|
||||
},
|
||||
{
|
||||
provide: OnlineBoardNavigationService,
|
||||
useValue: navigationService,
|
||||
},
|
||||
{
|
||||
provide: DictionariesService,
|
||||
useValue: dictionariesService,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(CanActivateArrivalSearch);
|
||||
});
|
||||
|
||||
it('should return true for valid arrival params without time range', async () => {
|
||||
const params = 'MOW-20220528';
|
||||
const result = await service.canActivate({
|
||||
params: {
|
||||
params,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid arrival params with time range', async () => {
|
||||
const params = 'MOW-20220528-06002200';
|
||||
const result = await service.canActivate({
|
||||
params: {
|
||||
params,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should redirect to not found because arrival city is invalid', async () => {
|
||||
const params = 'ABA-20220528';
|
||||
const result = await service.canActivate({
|
||||
params: {
|
||||
params,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).not.toBe(true);
|
||||
expect(toNotFoundMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should redirect to start page because date is invalid', async () => {
|
||||
const params = 'MOW-20220521';
|
||||
const result = await service.canActivate({
|
||||
params: {
|
||||
params,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).not.toBe(true);
|
||||
expect(toStartPageMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should redirect to start page because time range is invalid', async () => {
|
||||
const params = 'MOW-20220528-22000600';
|
||||
const result = await service.canActivate({
|
||||
params: {
|
||||
params,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).not.toBe(true);
|
||||
expect(toStartPageMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivate } from '@angular/router';
|
||||
import { OnlineBoardUrlParserService } from '@online-board/services/url/url-parser.service';
|
||||
import { CanActivateRouteParams } from './utils/can-activate-route-params';
|
||||
|
||||
@Injectable()
|
||||
export class CanActivateArrivalSearch implements CanActivate {
|
||||
constructor(
|
||||
private urlService: OnlineBoardUrlParserService,
|
||||
private routeParamsService: CanActivateRouteParams,
|
||||
) {}
|
||||
|
||||
canActivate(route: ActivatedRouteSnapshot) {
|
||||
const urlParams = this.urlService.parseArrivalSearchUrlParams(
|
||||
route.params.params,
|
||||
);
|
||||
|
||||
return this.routeParamsService.canActivate(urlParams);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
|
||||
import { CanActivateDepartureSearch } from '@online-board/guards/can-activate-departure-search';
|
||||
import { CanActivateDateParams } from '@online-board/guards/utils/can-activate-date-params';
|
||||
import { CanActivateRouteParams } from '@online-board/guards/utils/can-activate-route-params';
|
||||
import { CanActivateStations } from '@online-board/guards/utils/can-activate-stations';
|
||||
import { OnlineBoardDateValidationService } from '@online-board/services/date-validation.service';
|
||||
import { OnlineBoardNavigationService } from '@online-board/services/navigation.service';
|
||||
import { OnlineBoardUrlParserService } from '@online-board/services/url/url-parser.service';
|
||||
import { AppSettingsService } from '@shared/services';
|
||||
import { StationCodeValidationService } from '@shared/services/validators/station-code.service';
|
||||
import { TimeRangeValidationService } from '@shared/services/validators/time-range.service';
|
||||
|
||||
describe('CanActivateDepartureSearch', () => {
|
||||
let service: CanActivateDepartureSearch;
|
||||
|
||||
const boardMinDate = new Date(2022, 4, 23);
|
||||
const boardMaxDate = new Date(2022, 4, 29);
|
||||
const appSettingsService: Partial<AppSettingsService> = {
|
||||
getSettings: () => {
|
||||
return Promise.resolve({
|
||||
boardMinDate,
|
||||
boardMaxDate,
|
||||
}) as any;
|
||||
},
|
||||
};
|
||||
|
||||
const toNotFoundMock = jasmine.createSpy('toNotFound');
|
||||
const toStartPageMock = jasmine.createSpy('toStartPage');
|
||||
const navigationService: Partial<OnlineBoardNavigationService> = {
|
||||
toNotFound: toNotFoundMock,
|
||||
toStartPage: toStartPageMock,
|
||||
};
|
||||
|
||||
const dictionariesService: Partial<DictionariesService> = {
|
||||
ready$: Promise.resolve(),
|
||||
getCityOrAirport(code: string) {
|
||||
const map = new Map();
|
||||
map.set('MOW', true);
|
||||
map.set('LED', true);
|
||||
map.set('KUF', true);
|
||||
map.set('SVO', true);
|
||||
|
||||
return map.has(code) as any;
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
CanActivateDepartureSearch,
|
||||
CanActivateRouteParams,
|
||||
CanActivateStations,
|
||||
CanActivateDateParams,
|
||||
StationCodeValidationService,
|
||||
OnlineBoardDateValidationService,
|
||||
TimeRangeValidationService,
|
||||
OnlineBoardUrlParserService,
|
||||
{
|
||||
provide: AppSettingsService,
|
||||
useValue: appSettingsService,
|
||||
},
|
||||
{
|
||||
provide: OnlineBoardNavigationService,
|
||||
useValue: navigationService,
|
||||
},
|
||||
{
|
||||
provide: DictionariesService,
|
||||
useValue: dictionariesService,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(CanActivateDepartureSearch);
|
||||
});
|
||||
|
||||
it('should return true for valid departure params without time range', async () => {
|
||||
const params = 'MOW-20220528';
|
||||
const result = await service.canActivate({
|
||||
params: {
|
||||
params,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid departure params with time range', async () => {
|
||||
const params = 'MOW-20220528-06002200';
|
||||
const result = await service.canActivate({
|
||||
params: {
|
||||
params,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should redirect to not found because departure city is invalid', async () => {
|
||||
const params = 'ABA-20220528';
|
||||
const result = await service.canActivate({
|
||||
params: {
|
||||
params,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).not.toBe(true);
|
||||
expect(toNotFoundMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should redirect to start page because date is invalid', async () => {
|
||||
const params = 'MOW-20220521';
|
||||
const result = await service.canActivate({
|
||||
params: {
|
||||
params,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).not.toBe(true);
|
||||
expect(toStartPageMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should redirect to start page because time range is invalid', async () => {
|
||||
const params = 'MOW-20220528-22000600';
|
||||
const result = await service.canActivate({
|
||||
params: {
|
||||
params,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).not.toBe(true);
|
||||
expect(toStartPageMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user