Initial commit: Aeroflot Flights Web Angular 12 application

This commit is contained in:
2026-04-03 10:10:52 +03:00
commit 2342f2e66e
1311 changed files with 128350 additions and 0 deletions
@@ -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>
@@ -0,0 +1,12 @@
.map-wrapper {
position: relative;
height: 800px;
width: 100%;
}
.map {
height: 100%;
width: 100%;
}
@@ -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;
}
}
@@ -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>
@@ -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;
// }
@@ -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.');
}
}
@@ -0,0 +1,5 @@
<meta-tags
[title]="'SEO.FLIGHTS-MAP.MAIN.TITLE' | translate"
[description]="'SEO.FLIGHTS-MAP.MAIN.DESCRIPTION' | translate"
[noRobots]="false"
></meta-tags>
@@ -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{}
@@ -0,0 +1 @@
<aero-title [title]="title | translate"></aero-title>
@@ -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';
}
@@ -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>
@@ -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() {
}
}
@@ -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>
@@ -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);
}
}
}
@@ -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 {
}
}
@@ -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>
@@ -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;
}
@@ -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;
}
}
@@ -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>
@@ -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);
});
});
@@ -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,
};
}
}
@@ -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);
});
});
@@ -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;
}
}
@@ -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>
@@ -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);
});
});
@@ -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,
};
}
}
@@ -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);
});
});
@@ -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;
}
}
@@ -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>
@@ -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);
});
});
@@ -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);
}
}
@@ -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();
});
});
@@ -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 CanActivateDepartureSearch implements CanActivate {
constructor(
private urlService: OnlineBoardUrlParserService,
private routeParamsService: CanActivateRouteParams,
) {}
canActivate(route: ActivatedRouteSnapshot) {
const urlParams = this.urlService.parseDepartureSearchUrlParams(
route.params.params,
);
return this.routeParamsService.canActivate(urlParams);
}
}
@@ -0,0 +1,183 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateOnlineBoardDetails } from '@online-board/guards/can-activate-details';
import { CanActivateDateParams } from '@online-board/guards/utils/can-activate-date-params';
import { CanActivateRouteParams } from '@online-board/guards/utils/can-activate-route-params';
import { OnlineBoardNavigationService } from '@online-board/services/navigation.service';
import { OnlineBoardRequestParserService } from '@online-board/services/request/request-parser.service';
import { OnlineBoardUrlParserService } from '@online-board/services/url';
import { IParsedFlightId } from '@typings/flight/flight-id';
describe('CanActivateOnlineBoardDetails', () => {
let service: CanActivateOnlineBoardDetails;
const canActivateDateParamsMock = jasmine.createSpy(
'canActivateDateParams',
);
const validateDateParamsMock = jasmine.createSpy('validateDateParams');
const validateRouteParamsMock = jasmine.createSpy('validateRouteParams');
const parseMock = jasmine.createSpy('parse');
const toDetailsPageMock = jasmine.createSpy('toDetailsPage');
const parseFlightIdMock = jasmine.createSpy('parseFlightId');
const activatedRouteParams = {
params: 'SU0001-20220528',
};
const activatedRouteQueryParams = {
request: 'request string',
};
const activatedRoute = {} as any;
const parsedFlightId: IParsedFlightId = {
flightNumber: '0001',
carrier: 'SU',
date: new Date(2022, 4, 28),
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
CanActivateOnlineBoardDetails,
{
provide: OnlineBoardUrlParserService,
useValue: {
parseFlightId: parseFlightIdMock,
},
},
{
provide: CanActivateDateParams,
useValue: {
canActivate: canActivateDateParamsMock,
validateDateParams: validateDateParamsMock,
},
},
{
provide: CanActivateRouteParams,
useValue: {
validateRouteParams: validateRouteParamsMock,
},
},
{
provide: OnlineBoardRequestParserService,
useValue: {
parse: parseMock,
},
},
{
provide: OnlineBoardNavigationService,
useValue: {
toDetailsPage: toDetailsPageMock,
},
},
],
});
service = TestBed.inject(CanActivateOnlineBoardDetails);
canActivateDateParamsMock.calls.reset();
validateRouteParamsMock.calls.reset();
parseMock.calls.reset();
toDetailsPageMock.calls.reset();
activatedRoute.params = activatedRouteParams;
activatedRoute.queryParams = activatedRouteQueryParams;
parseFlightIdMock
.withArgs(activatedRoute.params.params)
.and.returnValue(parsedFlightId);
});
it('should return date validation result because it is not true', async () => {
const mockedResult = {};
canActivateDateParamsMock
.withArgs(parsedFlightId)
.and.returnValue(Promise.resolve(mockedResult));
const result = await service.canActivate(activatedRoute);
expect(result).toBe(mockedResult as any);
});
it('should return true because route params are valid and there is no request', async () => {
activatedRoute.queryParams = {
request: '',
};
canActivateDateParamsMock
.withArgs(parsedFlightId)
.and.returnValue(Promise.resolve(true));
const result = await service.canActivate(activatedRoute);
expect(result).toBe(true);
});
it('should return true if route params and request are valid', async () => {
const mockedParsingResult = {
type: 'flight-number',
params: {},
} as any;
canActivateDateParamsMock
.withArgs(parsedFlightId)
.and.returnValue(Promise.resolve(true));
parseMock
.withArgs(activatedRoute.queryParams.request)
.and.returnValue(mockedParsingResult);
validateDateParamsMock
.withArgs(mockedParsingResult.params)
.and.returnValue(Promise.resolve(true));
const result = await service.canActivate(activatedRoute);
expect(result).toBe(true);
});
it('should call toDetailsPage because flight number params are invalid', async () => {
const mockedParsingResult = {
type: 'flight-number',
params: {},
} as any;
canActivateDateParamsMock
.withArgs(parsedFlightId)
.and.returnValue(Promise.resolve(true));
parseMock
.withArgs(activatedRoute.queryParams.request)
.and.returnValue(mockedParsingResult);
validateDateParamsMock
.withArgs(mockedParsingResult.params)
.and.returnValue(Promise.resolve({}));
const result = await service.canActivate(activatedRoute);
expect(result).not.toBe(true);
expect(toDetailsPageMock).toHaveBeenCalledWith({
flightId: {
flightNumber: '0001',
carrier: 'SU',
date: '',
},
date: new Date(2022, 4, 28),
});
});
it('should call toDetailsPage because route params are invalid', async () => {
const mockedParsingResult = {
type: 'route',
params: {},
} as any;
canActivateDateParamsMock
.withArgs(parsedFlightId)
.and.returnValue(Promise.resolve(true));
parseMock
.withArgs(activatedRoute.queryParams.request)
.and.returnValue(mockedParsingResult);
validateRouteParamsMock
.withArgs(mockedParsingResult.params)
.and.returnValue(Promise.resolve({}));
const result = await service.canActivate(activatedRoute);
expect(result).not.toBe(true);
expect(toDetailsPageMock).toHaveBeenCalledWith({
flightId: {
flightNumber: '0001',
carrier: 'SU',
date: '',
},
date: new Date(2022, 4, 28),
});
});
});
@@ -0,0 +1,76 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate } from '@angular/router';
import { OnlineBoardNavigationService } from '@online-board/services/navigation.service';
import { OnlineBoardRequestParserService } from '@online-board/services/request/request-parser.service';
import { OnlineBoardUrlParserService } from '@online-board/services/url';
import { CanActivateDateParams } from './utils/can-activate-date-params';
import { CanActivateRouteParams } from './utils/can-activate-route-params';
import { CanActivateRedirrect } from './utils/can-activate-redirrect-to-not-found';
@Injectable()
export class CanActivateOnlineBoardDetails implements CanActivate {
constructor(
private urlService: OnlineBoardUrlParserService,
private dateParamsService: CanActivateDateParams,
private routeParamsService: CanActivateRouteParams,
private requestService: OnlineBoardRequestParserService,
private navigationService: OnlineBoardNavigationService,
private canRedirrect: CanActivateRedirrect,
) { }
async canActivate(route: ActivatedRouteSnapshot) {
const urlParams = this.urlService.parseFlightId(route.params.params);
if (!this.canRedirrect.validateFlightNumber(urlParams.flightNumber)) {
return this.navigationService.toNotFound();
}
const validationResult = await this.dateParamsService.canActivate(
urlParams,
);
if (validationResult !== true) {
return validationResult;
}
const requestValidationResult = await this.canActivateQueryParams(
route,
);
if (requestValidationResult !== true) {
return this.navigationService.toDetailsPage({
flightId: {
flightNumber: urlParams.flightNumber,
carrier: urlParams.carrier,
suffix: urlParams.suffix,
date: '',
},
date: urlParams.date,
});
}
return true;
}
private async canActivateQueryParams(route: ActivatedRouteSnapshot) {
const { request } = route.queryParams;
if (!request) {
return true;
}
const onlineBoardRequest = this.requestService.parse(request);
switch (onlineBoardRequest.type) {
case 'flight-number': {
return this.dateParamsService.validateDateParams(
onlineBoardRequest.params,
);
}
case 'route': {
return this.routeParamsService.validateRouteParams(
onlineBoardRequest.params,
);
}
}
}
}
@@ -0,0 +1,73 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFlightNumberSearch } from '@online-board/guards/can-activate-flight-number-search';
import { CanActivateDateParams } from '@online-board/guards/utils/can-activate-date-params';
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 { TimeRangeValidationService } from '@shared/services/validators/time-range.service';
describe('CanActivateFlightNumberSearch', () => {
let service: CanActivateFlightNumberSearch;
const toStartPageMock = jasmine.createSpy('toStartPage');
const navigationService: Partial<OnlineBoardNavigationService> = {
toStartPage: toStartPageMock,
};
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;
},
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
CanActivateDateParams,
CanActivateFlightNumberSearch,
TimeRangeValidationService,
OnlineBoardDateValidationService,
OnlineBoardUrlParserService,
{
provide: OnlineBoardNavigationService,
useValue: navigationService,
},
{
provide: AppSettingsService,
useValue: appSettingsService,
},
],
});
service = TestBed.inject(CanActivateFlightNumberSearch);
});
it('should return true for valid flight number search page params', async () => {
const params = 'SU0001-20220525';
const result = await service.canActivate({
params: {
params,
},
} as any);
expect(result).toBe(true);
});
it('should redirect to to start page because date is invalid', async () => {
const params = 'SU0001-20220521';
const result = await service.canActivate({
params: {
params,
},
} as any);
expect(result).not.toBe(true);
expect(toStartPageMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,26 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate } from '@angular/router';
import { CanActivateDateParams } from '@online-board/guards/utils/can-activate-date-params';
import { OnlineBoardUrlParserService } from '@online-board/services/url/url-parser.service';
import { CanActivateRedirrect } from './utils/can-activate-redirrect-to-not-found';
import { OnlineBoardNavigationService } from '../services/navigation.service';
@Injectable()
export class CanActivateFlightNumberSearch implements CanActivate {
constructor(
private urlService: OnlineBoardUrlParserService,
private dateParamsService: CanActivateDateParams,
private canRedirrect: CanActivateRedirrect,
private navigationService: OnlineBoardNavigationService,
) {}
async canActivate(route: ActivatedRouteSnapshot) {
const urlParams = this.urlService.parseFlightId(route.params.params);
if (!this.canRedirrect.validateFlightNumber(urlParams.flightNumber)) {
return this.navigationService.toNotFound();
}
return this.dateParamsService.canActivate(urlParams);
}
}
@@ -0,0 +1,146 @@
import { TestBed } from '@angular/core/testing';
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
import { CanActivateRouteSearch } from '@online-board/guards/can-activate-route-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('CanActivateRouteSearch', () => {
let service: CanActivateRouteSearch;
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: [
CanActivateRouteSearch,
CanActivateRouteParams,
CanActivateStations,
CanActivateDateParams,
StationCodeValidationService,
OnlineBoardDateValidationService,
TimeRangeValidationService,
OnlineBoardUrlParserService,
{
provide: AppSettingsService,
useValue: appSettingsService,
},
{
provide: OnlineBoardNavigationService,
useValue: navigationService,
},
{
provide: DictionariesService,
useValue: dictionariesService,
},
],
});
service = TestBed.inject(CanActivateRouteSearch);
});
it('should return true for valid route params without time range', async () => {
const params = 'MOW-LED-20220528';
const result = await service.canActivate({
params: {
params,
},
} as any);
expect(result).toBe(true);
});
it('should return true for valid route params with time range', async () => {
const params = 'MOW-LED-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-LED-20220528';
const result = await service.canActivate({
params: {
params,
},
} as any);
expect(result).not.toBe(true);
expect(toNotFoundMock).toHaveBeenCalled();
});
it('should redirect to not found because arrival city is invalid', async () => {
const params = 'MOW-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-LED-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-LED-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 CanActivateRouteSearch implements CanActivate {
constructor(
private urlService: OnlineBoardUrlParserService,
private routeParamsService: CanActivateRouteParams,
) {}
async canActivate(route: ActivatedRouteSnapshot) {
const urlParams = this.urlService.parseRouteSearchUrlParams(
route.params.params,
);
return this.routeParamsService.canActivate(urlParams);
}
}
@@ -0,0 +1,88 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateDateParams } from '@online-board/guards/utils/can-activate-date-params';
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 { TimeRangeValidationService } from '@shared/services/validators/time-range.service';
describe('CanActivateDateParams', () => {
let service: CanActivateDateParams;
const boardMinDate = new Date(2022, 4, 23);
const boardMaxDate = new Date(2022, 4, 29);
const toStartPageMock = jasmine.createSpy('toStartPage');
const navigationService: Partial<OnlineBoardNavigationService> = {
toStartPage: toStartPageMock,
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
CanActivateDateParams,
OnlineBoardDateValidationService,
TimeRangeValidationService,
OnlineBoardUrlParserService,
{
provide: AppSettingsService,
useValue: {
getSettings: () => {
return Promise.resolve({
boardMinDate,
boardMaxDate,
});
},
},
},
{
provide: OnlineBoardNavigationService,
useValue: navigationService,
},
],
});
toStartPageMock.calls.reset();
service = TestBed.inject(CanActivateDateParams);
});
it('can activate with valid date', async () => {
const validDate = new Date(2022, 4, 25);
const result = await service.canActivate({
date: validDate,
});
expect(result).toBe(true);
});
it('can activate with valid date and time range', async () => {
const validDate = new Date(2022, 4, 25);
const result = await service.canActivate({
date: validDate,
timeFrom: '0600',
timeTo: '2200',
});
expect(result).toBe(true);
});
it('redirect to start page because of invalid date', async () => {
const validDate = new Date(2022, 4, 21);
const result = await service.canActivate({
date: validDate,
});
expect(result).not.toBe(true);
expect(toStartPageMock).toHaveBeenCalled();
});
it('redirect to start page because of invalid time range', async () => {
const validDate = new Date(2022, 4, 25);
const result = await service.canActivate({
date: validDate,
timeFrom: '1200',
timeTo: '1000',
});
expect(result).not.toBe(true);
expect(toStartPageMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
import { OnlineBoardDateValidationService } from '@online-board/services/date-validation.service';
import { OnlineBoardNavigationService } from '@online-board/services/navigation.service';
import { IOnlineBoardUrlDateParams } from '@online-board/services/url/types';
import { TimeRangeValidationService } from '@shared/services/validators/time-range.service';
import { CanActivateRedirrect } from './can-activate-redirrect-to-not-found';
@Injectable()
export class CanActivateDateParams {
constructor(
private dateValidator: OnlineBoardDateValidationService,
private timeRangeValidator: TimeRangeValidationService,
private navigationService: OnlineBoardNavigationService,
private canRedirrect: CanActivateRedirrect,
) {}
public async canActivate(params: IOnlineBoardUrlDateParams) {
const validationResult = await this.validateDateParams(params);
const isTimeRangeValid = this.canRedirrect.validateTimeRange(params.timeFrom, params.timeTo);
if (!isTimeRangeValid || !params.date) {
return this.navigationService.toNotFound();
}
return validationResult ? true : this.navigationService.toStartPage();
}
public async validateDateParams(params: IOnlineBoardUrlDateParams) {
const isDateValid = await this.dateValidator.isDateValid(params.date);
const isTimeRangeValid = this.timeRangeValidator.isTimeRangeValid(
params.timeFrom,
params.timeTo,
);
return !(!isDateValid || !isTimeRangeValid);
}
}
@@ -0,0 +1,35 @@
import { Injectable } from '@angular/core';
@Injectable()
export class CanActivateRedirrect {
public validateStation(station: string) {
if (!station) {
return true;
}
const pattern: RegExp = /\w{3}/g;
let match = station.match(pattern);
return match && match[0] == station;
}
public validateTimeRange(timeFrom: string, timeTo: string) {
const pattern: RegExp = /\d{4}/g;
if (!timeFrom && !timeTo) {
return true;
}
let matchFrom = timeFrom.match(pattern);
let matchTo = timeTo.match(pattern);
return matchFrom && matchFrom[0] == timeFrom && matchTo && matchTo[0] == timeTo;
}
public validateFlightNumber(number: string) {
const pattern: RegExp = /\d{4}/g;
let match = number.match(pattern);
return match && match[0] == number
}
}
@@ -0,0 +1,218 @@
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 { 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 { IOnlineBoardRoutePageUrlParams } from '@online-board/services/url/types';
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('CanActivateRouteParams', () => {
let service: CanActivateRouteParams;
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,
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
CanActivateRouteParams,
CanActivateStations,
CanActivateDateParams,
StationCodeValidationService,
OnlineBoardDateValidationService,
TimeRangeValidationService,
OnlineBoardUrlParserService,
{
provide: AppSettingsService,
useValue: appSettingsService,
},
{
provide: OnlineBoardNavigationService,
useValue: navigationService,
},
{
provide: DictionariesService,
useValue: getDictionariesServiceMock(),
},
],
});
toStartPageMock.calls.reset();
toNotFoundMock.calls.reset();
service = TestBed.inject(CanActivateRouteParams);
});
it('should return true for valid arrival params without time range', async () => {
const params: IOnlineBoardRoutePageUrlParams = {
arrival: 'MOW',
date: new Date(2022, 4, 25),
};
const activationResult = await service.canActivate(params);
expect(activationResult).toBe(true);
const validationResult = await service.validateRouteParams(params);
expect(validationResult).toBe(true);
});
it('should return true for valid arrival params with time range', async () => {
const params: IOnlineBoardRoutePageUrlParams = {
arrival: 'MOW',
date: new Date(2022, 4, 25),
timeFrom: '0600',
timeTo: '2200',
};
const activationResult = await service.canActivate(params);
expect(activationResult).toBe(true);
const validationResult = await service.validateRouteParams(params);
expect(validationResult).toBe(true);
});
it('should return true for valid departure params without time range', async () => {
const params: IOnlineBoardRoutePageUrlParams = {
departure: 'MRS',
date: new Date(2022, 4, 25),
};
const activationResult = await service.canActivate(params);
expect(activationResult).toBe(true);
const validationResult = await service.validateRouteParams(params);
expect(validationResult).toBe(true);
});
it('should return true for valid departure params with time range', async () => {
const params: IOnlineBoardRoutePageUrlParams = {
departure: 'MRS',
date: new Date(2022, 4, 25),
timeFrom: '0600',
timeTo: '2200',
};
const activationResult = await service.canActivate(params);
expect(activationResult).toBe(true);
const validationResult = await service.validateRouteParams(params);
expect(validationResult).toBe(true);
});
it('should return true for valid route params without time range', async () => {
const params: IOnlineBoardRoutePageUrlParams = {
departure: 'MRS',
arrival: 'MOW',
date: new Date(2022, 4, 25),
};
const activationResult = await service.canActivate(params);
expect(activationResult).toBe(true);
const validationResult = await service.validateRouteParams(params);
expect(validationResult).toBe(true);
});
it('should return true for valid route params with time range', async () => {
const params: IOnlineBoardRoutePageUrlParams = {
departure: 'MRS',
arrival: 'MOW',
date: new Date(2022, 4, 25),
timeFrom: '0600',
timeTo: '2200',
};
const activationResult = await service.canActivate(params);
expect(activationResult).toBe(true);
const validationResult = await service.validateRouteParams(params);
expect(validationResult).toBe(true);
});
it('should not return true because date is invalid', async () => {
const params: IOnlineBoardRoutePageUrlParams = {
departure: 'MRS',
arrival: 'MOW',
date: new Date(2022, 4, 21),
timeFrom: '0600',
timeTo: '2200',
};
const activationResult = await service.canActivate(params);
expect(activationResult).not.toBe(true);
expect(toStartPageMock).toHaveBeenCalled();
const validationResult = await service.validateRouteParams(params);
expect(validationResult).not.toBe(true);
});
it('should not return true because time range is invalid', async () => {
const params: IOnlineBoardRoutePageUrlParams = {
departure: 'MRS',
arrival: 'MOW',
date: new Date(2022, 4, 25),
timeFrom: '0600',
timeTo: '0300',
};
const activationResult = await service.canActivate(params);
expect(activationResult).not.toBe(true);
expect(toStartPageMock).toHaveBeenCalled();
const validationResult = await service.validateRouteParams(params);
expect(validationResult).not.toBe(true);
});
it('should not return true because departure is invalid', async () => {
const params: IOnlineBoardRoutePageUrlParams = {
departure: 'ABA',
arrival: 'MOW',
date: new Date(2022, 4, 25),
timeFrom: '0600',
timeTo: '0300',
};
const activationResult = await service.canActivate(params);
expect(activationResult).not.toBe(true);
expect(toNotFoundMock).toHaveBeenCalled();
const validationResult = await service.validateRouteParams(params);
expect(validationResult).not.toBe(true);
});
it('should not return true because arrival is invalid', async () => {
const params: IOnlineBoardRoutePageUrlParams = {
departure: 'MRS',
arrival: 'ABA',
date: new Date(2022, 4, 25),
timeFrom: '0600',
timeTo: '0300',
};
const result = await service.canActivate(params);
expect(result).not.toBe(true);
expect(toNotFoundMock).toHaveBeenCalled();
const validationResult = await service.validateRouteParams(params);
expect(validationResult).not.toBe(true);
});
});
@@ -0,0 +1,56 @@
import { Injectable } from '@angular/core';
import { IOnlineBoardRoutePageUrlParams } from '@online-board/services/url/types';
import { CanActivateDateParams } from './can-activate-date-params';
import { CanActivateStations } from './can-activate-stations';
import { CanActivateRedirrect } from './can-activate-redirrect-to-not-found';
import { OnlineBoardNavigationService } from '../../services/navigation.service';
@Injectable()
export class CanActivateRouteParams {
constructor(
private stationsService: CanActivateStations,
private dateParamsService: CanActivateDateParams,
private canRedirrect: CanActivateRedirrect,
private navigationService: OnlineBoardNavigationService,
) {}
public async canActivate(params: IOnlineBoardRoutePageUrlParams) {
if (!this.canRedirrect.validateStation(params.arrival) || !this.canRedirrect.validateStation(params.departure)) {
return this.navigationService.toNotFound();
}
const canActivateStations = await this.stationsService.canActivate(
params,
);
if (canActivateStations !== true) {
return canActivateStations;
}
if (!params.date || !this.canRedirrect.validateTimeRange(params.timeFrom, params.timeTo)) {
return this.navigationService.toNotFound();
}
const canActivateDateParams = await this.dateParamsService.canActivate(
params,
);
if (canActivateDateParams !== true) {
return canActivateDateParams;
}
return true;
}
public async validateRouteParams(params: IOnlineBoardRoutePageUrlParams) {
const canActivateStations = await this.stationsService.validateStations(
params,
);
const canActivateDateParams =
await this.dateParamsService.validateDateParams(params);
return canActivateDateParams && canActivateStations;
}
}
@@ -0,0 +1,96 @@
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 { CanActivateStations } from '@online-board/guards/utils/can-activate-stations';
import { OnlineBoardNavigationService } from '@online-board/services/navigation.service';
import { StationCodeValidationService } from '@shared/services/validators/station-code.service';
describe('CanActivateStations', () => {
let service: CanActivateStations;
const toNotFoundMock = jasmine.createSpy('toNotFound');
const navigationService: Partial<OnlineBoardNavigationService> = {
toNotFound: toNotFoundMock,
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
CanActivateStations,
StationCodeValidationService,
{
provide: DictionariesService,
useValue: getDictionariesServiceMock(),
},
{
provide: OnlineBoardNavigationService,
useValue: navigationService,
},
],
});
toNotFoundMock.calls.reset();
service = TestBed.inject(CanActivateStations);
});
it('can activate arrival station', async () => {
const result = await service.canActivate({
arrival: 'MOW',
});
expect(result).toBe(true);
});
it('can activate departure station', async () => {
const result = await service.canActivate({
departure: 'MRS',
});
expect(result).toBe(true);
});
it('can activate both stations', async () => {
const result = await service.canActivate({
arrival: 'MOW',
departure: 'MRS',
});
expect(result).toBe(true);
});
it('redirect to not found because arrival is not valid', async () => {
const result = await service.canActivate({
arrival: 'ABA',
departure: 'LED',
});
expect(result).not.toBe(true);
expect(toNotFoundMock).toHaveBeenCalled();
});
it('redirect to not found because departure is not valid', async () => {
const result = await service.canActivate({
arrival: 'MOW',
departure: 'ABA',
});
expect(result).not.toBe(true);
expect(toNotFoundMock).toHaveBeenCalled();
});
it('redirect to not found because both stations are not valid', async () => {
const result = await service.canActivate({
arrival: 'KZN',
departure: 'ABA',
});
expect(result).not.toBe(true);
expect(toNotFoundMock).toHaveBeenCalled();
});
it('should redirect to not found because neither departure nor arrival were provided', async () => {
const result = await service.canActivate({} as any);
expect(result).not.toBe(true);
expect(toNotFoundMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,48 @@
import { Injectable } from '@angular/core';
import { StationCodeValidationService } from '@shared/services/validators/station-code.service';
import { OnlineBoardNavigationService } from '@online-board/services/navigation.service';
import { IOnlineBoardRoutePageUrlParams } from '@online-board/services/url/types';
import { CanActivate } from '@angular/router';
import { CanActivateRedirrect } from './can-activate-redirrect-to-not-found';
type IStations = Pick<IOnlineBoardRoutePageUrlParams, 'arrival' | 'departure'>;
@Injectable()
export class CanActivateStations {
constructor(
private stationValidator: StationCodeValidationService,
private navigationService: OnlineBoardNavigationService,
private canRedirrect: CanActivateRedirrect,
) {}
public async canActivate(stations: IStations) {
let validationResult = await this.validateStations(stations);
validationResult = validationResult && this.canRedirrect.validateStation(stations.departure) && this.canRedirrect.validateStation(stations.arrival);
return validationResult ? true : this.navigationService.toNotFound();
}
public async validateStations(stations: IStations) {
if (!stations.arrival && !stations.departure) {
return false;
}
let isArrivalValid = true;
let isDepartureValid = true;
if (stations.arrival) {
isArrivalValid = await this.stationValidator.isStationCodeValid(
stations.arrival,
);
}
if (stations.departure) {
isDepartureValid = await this.stationValidator.isStationCodeValid(
stations.departure,
);
}
return !(!isArrivalValid || !isDepartureValid);
}
}
@@ -0,0 +1,80 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CanActivateArrivalSearch } from './guards/can-activate-arrival-search';
import { CanActivateDepartureSearch } from './guards/can-activate-departure-search';
import { CanActivateOnlineBoardDetails } from './guards/can-activate-details';
import { CanActivateRouteSearch } from './guards/can-activate-route-search';
import { CanActivateFlightNumberSearch } from './guards/can-activate-flight-number-search';
import { ArrivalSearchPageComponent } from './pages/search/arrival/arrival-search-page.component';
import { DepartureSearchPageComponent } from './pages/search/departure/departure-search-page.component';
import { OnlineBoardFlightDetailsPageComponent } from './pages/flight-details/flight-details-page.component';
import { FlightNumberSearchPageComponent } from './pages/search/flight-number-search/flight-number-search-page.component';
import { RouteSearchPageComponent } from './pages/search/route-search/route-search-page.component';
import { OnlineBoardStartPageComponent } from './pages/start/online-board-start-page.component';
import { OnlineBoardDetailsBreadCrumbsResolver } from './resolvers/bread-crumbs/details-bread-crumbs.resolver';
import { OnlineBoardRouteSearchBreadCrumbsResolver } from './resolvers/bread-crumbs/route-search-bread-crumbs.resolver';
import { OnlineBoardArrivalUrlParamsResolver } from './resolvers/url-params/arrival-url-params.resolver';
import { OnlineBoardDepartureUrlParamsResolver } from './resolvers/url-params/departure-url-params.resolver';
import { OnlineBoardDetailsUrlParamsResolver } from './resolvers/url-params/details-url-params.resolver';
import { OnlineBoardFlightNumberUrlParamsResolver } from './resolvers/url-params/flight-number-url-params.resolver';
import { OnlineBoardRequestDataResolver } from './resolvers/request-data.resolver';
import { OnlineBoardRouteUrlParamsResolver } from './resolvers/url-params/route-url-params.resolver';
const routes: Routes = [
{
path: 'flight/:params', // params are {flightNumber}-{flightDate}
component: FlightNumberSearchPageComponent,
canActivate: [CanActivateFlightNumberSearch],
resolve: {
urlParams: OnlineBoardFlightNumberUrlParamsResolver,
breadcrumbs: OnlineBoardRouteSearchBreadCrumbsResolver,
},
},
{
path: 'departure/:params', // params are {departure}-{flightDate}-{timeFrom}{timeTo}
component: DepartureSearchPageComponent,
canActivate: [CanActivateDepartureSearch],
resolve: {
urlParams: OnlineBoardDepartureUrlParamsResolver,
breadcrumbs: OnlineBoardRouteSearchBreadCrumbsResolver,
},
},
{
path: 'arrival/:params', // params are {arrival}-{flightDate}-{timeFrom}{timeTo}
component: ArrivalSearchPageComponent,
canActivate: [CanActivateArrivalSearch],
resolve: {
urlParams: OnlineBoardArrivalUrlParamsResolver,
breadcrumbs: OnlineBoardRouteSearchBreadCrumbsResolver,
},
},
{
path: 'route/:params', // params are {departure}-{arrival}-{flightDate}-{timeFrom}{timeTo}
component: RouteSearchPageComponent,
canActivate: [CanActivateRouteSearch],
resolve: {
urlParams: OnlineBoardRouteUrlParamsResolver,
breadcrumbs: OnlineBoardRouteSearchBreadCrumbsResolver,
},
},
{
path: ':params', // params are {flightNumber}-{flightDate}
component: OnlineBoardFlightDetailsPageComponent,
canActivate: [CanActivateOnlineBoardDetails],
resolve: {
urlParams: OnlineBoardDetailsUrlParamsResolver,
requestData: OnlineBoardRequestDataResolver,
breadcrumbs: OnlineBoardDetailsBreadCrumbsResolver,
},
},
{
path: '',
component: OnlineBoardStartPageComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class OnlineBoardRoutingModule {}
@@ -0,0 +1,141 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ComponentsModule } from '@components/components.module';
import { FlightsModule } from '@modules/flights.module';
import { SharedModule } from '@shared';
import { ToolkitModule } from '@toolkit/toolkit.module';
import { PopularRequestsModule } from '../popular-requests/popular-requests.module';
import { OnlineBoardFilterModule } from './components/filter/online-board-filter.module';
import { CanActivateArrivalSearch } from './guards/can-activate-arrival-search';
import { CanActivateDepartureSearch } from './guards/can-activate-departure-search';
import { CanActivateOnlineBoardDetails } from './guards/can-activate-details';
import { CanActivateFlightNumberSearch } from './guards/can-activate-flight-number-search';
import { CanActivateRouteSearch } from './guards/can-activate-route-search';
import { CanActivateDateParams } from './guards/utils/can-activate-date-params';
import { CanActivateRouteParams } from './guards/utils/can-activate-route-params';
import { CanActivateStations } from './guards/utils/can-activate-stations';
import { OnlineBoardRoutingModule } from './online-board-routing.module';
import { CanActivateRedirrect } from './guards/utils/can-activate-redirrect-to-not-found';
import { OnlineBoardFlightsMiniListComponent } from './pages/flight-details/components/flights-mini-list/online-board-flights-mini-list.component';
import { OnlineBoardFlightDetailsMetaTagsComponent } from './pages/flight-details/components/meta-tags/flight-details-meta-tags.component';
import { OnlineBoardFlightDetailsTitleComponent } from './pages/flight-details/components/title/flight-details-title.component';
import { OnlineBoardDetailsViewComponent } from './pages/flight-details/components/view/online-board-details.component';
import { OnlineBoardFlightDetailsPageComponent } from './pages/flight-details/flight-details-page.component';
import { ArrivalSearchPageComponent } from './pages/search/arrival/arrival-search-page.component';
import { OnlineBoardArrivalMetaTagsComponent } from './pages/search/arrival/components/meta-tags/online-board-arrival-meta-tags.component';
import { OnlineBoardArrivalTitleComponent } from './pages/search/arrival/components/title/online-board-arrival-title.component';
import { OnlineBoardSearchPageBaseComponent } from './pages/search/components/base/search-page-base.component';
import { OnlineBoardSearchComponent } from './pages/search/components/view/online-board-search.component';
import { OnlineBoardDepartureMetaTagsComponent } from './pages/search/departure/components/meta-tags/online-board-departure-meta-tags.component';
import { OnlineBoardDepartureTitleComponent } from './pages/search/departure/components/title/online-board-departure-title.component';
import { DepartureSearchPageComponent } from './pages/search/departure/departure-search-page.component';
import { OnlineBoardFlightNumberMetaTagsComponent } from './pages/search/flight-number-search/components/meta-tags/online-board-flight-number-meta-tags.component';
import { OnlineBoardFlightNumberTitleComponent } from './pages/search/flight-number-search/components/title/online-board-flight-number-title.component';
import { FlightNumberSearchPageComponent } from './pages/search/flight-number-search/flight-number-search-page.component';
import { OnlineBoardRouteMetaTagsComponent } from './pages/search/route-search/components/meta-tags/online-board-route-meta-tags.component';
import { OnlineBoardRouteTitleComponent } from './pages/search/route-search/components/title/online-board-route-title.component';
import { RouteSearchPageComponent } from './pages/search/route-search/route-search-page.component';
import { OnlineBoardStartPageMetaTagsComponent } from './pages/start/components/meta-tags/online-board-start-page-meta-tags.component';
import { OnlineBoardStartPageTitleComponent } from './pages/start/components/title/start-page-title.component';
import { OnlineBoardStartPageComponent } from './pages/start/online-board-start-page.component';
import { OnlineBoardDetailsBreadCrumbsResolver } from './resolvers/bread-crumbs/details-bread-crumbs.resolver';
import { OnlineBoardRouteSearchBreadCrumbsResolver } from './resolvers/bread-crumbs/route-search-bread-crumbs.resolver';
import { OnlineBoardRequestDataResolver } from './resolvers/request-data.resolver';
import { OnlineBoardArrivalUrlParamsResolver } from './resolvers/url-params/arrival-url-params.resolver';
import { OnlineBoardDepartureUrlParamsResolver } from './resolvers/url-params/departure-url-params.resolver';
import { OnlineBoardDetailsUrlParamsResolver } from './resolvers/url-params/details-url-params.resolver';
import { OnlineBoardFlightNumberUrlParamsResolver } from './resolvers/url-params/flight-number-url-params.resolver';
import { OnlineBoardRouteUrlParamsResolver } from './resolvers/url-params/route-url-params.resolver';
import { OnlineBoardApiService } from './services/api.service';
import { OnlineBoardBreadCrumbService } from './services/bread-crumb.service';
import { OnlineBoardDataSourceService } from './services/data-source.service';
import { OnlineBoardDateValidationService } from './services/date-validation.service';
import { OnlineBoardFilterService } from './services/filter/filter.service';
import { OnlineBoardMetaTagsService } from './services/meta-tags.service';
import { OnlineBoardNavigationService } from './services/navigation.service';
import { OnlineBoardRequestBuilderService } from './services/request/request-builder.service';
import { OnlineBoardRequestParserService } from './services/request/request-parser.service';
import { OnlineBoardTitleService } from './services/title.service';
import { FadeService } from './services/fade.service';
import {
ONLINE_BOARD_URL_BASE,
OnlineBoardUrlBuilderService,
OnlineBoardUrlParserService,
} from './services/url';
@NgModule({
declarations: [
OnlineBoardStartPageComponent,
FlightNumberSearchPageComponent,
DepartureSearchPageComponent,
ArrivalSearchPageComponent,
RouteSearchPageComponent,
OnlineBoardFlightDetailsPageComponent,
OnlineBoardArrivalMetaTagsComponent,
OnlineBoardArrivalTitleComponent,
OnlineBoardSearchComponent,
OnlineBoardDepartureMetaTagsComponent,
OnlineBoardDepartureTitleComponent,
OnlineBoardRouteTitleComponent,
OnlineBoardRouteMetaTagsComponent,
OnlineBoardFlightNumberMetaTagsComponent,
OnlineBoardFlightNumberTitleComponent,
OnlineBoardDetailsViewComponent,
OnlineBoardFlightDetailsTitleComponent,
OnlineBoardFlightDetailsMetaTagsComponent,
OnlineBoardStartPageMetaTagsComponent,
OnlineBoardStartPageTitleComponent,
OnlineBoardFlightsMiniListComponent,
OnlineBoardSearchPageBaseComponent,
],
providers: [
OnlineBoardApiService,
OnlineBoardDataSourceService,
OnlineBoardDateValidationService,
CanActivateFlightNumberSearch,
CanActivateRouteSearch,
CanActivateArrivalSearch,
CanActivateDepartureSearch,
CanActivateStations,
CanActivateRedirrect,
CanActivateDateParams,
CanActivateRouteParams,
OnlineBoardNavigationService,
OnlineBoardUrlBuilderService,
OnlineBoardUrlParserService,
OnlineBoardRequestBuilderService,
OnlineBoardRequestParserService,
OnlineBoardFilterService,
OnlineBoardArrivalUrlParamsResolver,
OnlineBoardDepartureUrlParamsResolver,
OnlineBoardFlightNumberUrlParamsResolver,
OnlineBoardRouteUrlParamsResolver,
OnlineBoardTitleService,
OnlineBoardMetaTagsService,
OnlineBoardDetailsUrlParamsResolver,
OnlineBoardRequestDataResolver,
OnlineBoardBreadCrumbService,
OnlineBoardRouteSearchBreadCrumbsResolver,
OnlineBoardDetailsBreadCrumbsResolver,
FadeService,
CanActivateOnlineBoardDetails,
{
provide: ONLINE_BOARD_URL_BASE,
useValue: 'onlineboard',
},
],
imports: [
CommonModule,
OnlineBoardRoutingModule,
FlightsModule,
SharedModule,
ToolkitModule,
FormsModule,
ComponentsModule,
OnlineBoardFilterModule,
PopularRequestsModule,
],
})
export class OnlineBoardModule {}
@@ -0,0 +1,16 @@
<section
class="frame frame-left-panel single-flights-details afl-scrollbar"
scrollContainer
>
<flights-details-list-flight
*ngFor="let $flight of flights"
[flightDate]="$flight.dateToSearchBy"
[isBoard]="true"
[isSchedule]="false"
[scrollTo]="isFlightSelected($flight)"
[flight]="$flight"
(click)="openFlight($flight)"
[ngClass]="{ selected: isFlightSelected($flight) }"
>
</flights-details-list-flight>
</section>
@@ -0,0 +1,61 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OnlineBoardFlightsMiniListComponent } from '@online-board/pages/flight-details/components/flights-mini-list/online-board-flights-mini-list.component';
describe('OnlineBoardFlightsMiniListComponent', () => {
let fixture: ComponentFixture<OnlineBoardFlightsMiniListComponent>;
let component: OnlineBoardFlightsMiniListComponent;
const emitMock = jasmine.createSpy('emit');
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [OnlineBoardFlightsMiniListComponent],
schemas: [NO_ERRORS_SCHEMA],
});
});
beforeEach(() => {
fixture = TestBed.createComponent(OnlineBoardFlightsMiniListComponent);
component = fixture.componentInstance;
(component.open as any) = {
emit: emitMock,
};
emitMock.calls.reset();
});
it('should not emit open event because no flight was provided', () => {
component.openFlight();
expect(emitMock).not.toHaveBeenCalled();
});
it('should emit open event', () => {
const flight = { pId: '111' } as any;
component.openFlight(flight);
expect(emitMock).toHaveBeenCalledWith(flight);
});
it('should return false because no flights were provided', () => {
component.flight = { pId: '111' } as any;
expect(component.isFlightSelected({ pId: '111' } as any)).toBe(false);
});
it('should return false because flights array contains only one item', () => {
const flight = { pId: '111' } as any;
component.flightsLegacy = [flight];
expect(component.isFlightSelected(flight)).toBe(false);
});
it('should return true if flight is selected', () => {
const firstFlight = { pId: '111' } as any;
const secondFlight = { pId: '222' } as any;
component.flightsLegacy = [firstFlight, secondFlight] as any;
component.flight = firstFlight;
expect(component.isFlightSelected(firstFlight)).toBe(true);
expect(component.isFlightSelected(secondFlight)).toBe(false);
});
});
@@ -0,0 +1,41 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core';
import { FlightModel } from '@shared/models-legacy';
import { compareFlightsByPId } from '@utils/flight/p-id';
@Component({
selector: 'online-board-flights-mini-list',
templateUrl: './online-board-flights-mini-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OnlineBoardFlightsMiniListComponent {
@Input() flight: FlightModel;
@Input('flights') flightsLegacy: FlightModel[] = [];
@Output() open = new EventEmitter<FlightModel>();
public openFlight(flight?: FlightModel) {
if (!flight) {
return;
}
this.open.emit(flight);
}
public isFlightSelected(flight: FlightModel) {
if (this.flights.length === 1) {
return false;
}
return compareFlightsByPId(this.flight as any, flight as any);
}
get flights() {
return this.flightsLegacy.length ? this.flightsLegacy : [this.flight];
}
}
@@ -0,0 +1,5 @@
<meta-tags
[title]="title"
[description]="description"
[canonical]="'fullURL'"
></meta-tags>
@@ -0,0 +1,111 @@
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 { TranslatePipe } from '@ngx-translate/core';
import { OnlineBoardFlightDetailsMetaTagsComponent } from '@online-board/pages/flight-details/components/meta-tags/flight-details-meta-tags.component';
import { OnlineBoardUrlParserService } from '@online-board/services/url';
import { DateTransformerService } from '@shared/services/date/date-transformer.service';
import { BehaviorSubject } from 'rxjs';
describe('OnlineBoardFlightDetailsMetaTagsComponent', () => {
let fixture: ComponentFixture<OnlineBoardFlightDetailsMetaTagsComponent>;
let component: OnlineBoardFlightDetailsMetaTagsComponent;
const date = new Date(2022, 4, 28);
const transformedDate = '2022-05-28';
const routeParams = {
params: 'SU0001-20220528',
};
const title = 'flight details meta tag title';
const description = 'flight details meta tag description';
const getCityOrAirportMock = jasmine.createSpy('getCityOrAirport');
const transformMock = jasmine
.createSpy('transform')
.withArgs('SEO.BOARD.FLIGHT-DETAILS.TITLE', {
flightNumber: 'SU 0001',
date: transformedDate,
})
.and.returnValue(title)
.withArgs('SEO.BOARD.FLIGHT-DETAILS.DESCRIPTION', {
flightNumber: 'SU 0001',
date: transformedDate,
})
.and.returnValue(description);
const parseFlightIdMock = jasmine
.createSpy('parseFlightId')
.withArgs(routeParams.params)
.and.returnValue({
flightNumber: '0001',
carrier: 'SU',
date,
});
const toStringMock = jasmine
.createSpy('toString')
.withArgs(date)
.and.returnValue(transformedDate);
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [OnlineBoardFlightDetailsMetaTagsComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: new BehaviorSubject(routeParams),
},
},
{
provide: DictionariesService,
useValue: {
ready$: Promise.resolve(),
getCityOrAirport: getCityOrAirportMock,
},
},
{
provide: TranslatePipe,
useValue: {
transform: transformMock,
},
},
{
provide: OnlineBoardUrlParserService,
useValue: {
parseFlightId: parseFlightIdMock,
},
},
{
provide: DateTransformerService,
useValue: {
toString: toStringMock,
},
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(
OnlineBoardFlightDetailsMetaTagsComponent,
);
component = fixture.componentInstance;
getCityOrAirportMock.calls.reset();
transformMock.calls.reset();
parseFlightIdMock.calls.reset();
toStringMock.calls.reset();
});
it('should set title and description fields', async () => {
component.ngOnInit();
await Promise.resolve();
expect(component.title).toBe(title);
expect(component.description).toBe(description);
});
});
@@ -0,0 +1,46 @@
import { Component, Input } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { MetaBaseComponent } from '@components/meta-base/meta-base.component';
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
import { TranslatePipe } from '@ngx-translate/core';
import { OnlineBoardUrlParserService } from '@online-board/services/url/url-parser.service';
import { DateTransformerService } from '@shared/services/date/date-transformer.service';
import { IFlight } from '@typings/flight/flight';
@Component({
selector: 'online-board-flight-details-meta-tags',
templateUrl: './flight-details-meta-tags.component.html',
})
export class OnlineBoardFlightDetailsMetaTagsComponent extends MetaBaseComponent {
@Input() flight: IFlight;
constructor(
protected route: ActivatedRoute,
protected dictionaries: DictionariesService,
private translatePipe: TranslatePipe,
private urlService: OnlineBoardUrlParserService,
private dateService: DateTransformerService,
) {
super(route, dictionaries);
}
private translate(tag: 'TITLE' | 'DESCRIPTION', params: string) {
const { flightNumber, carrier, date } =
this.urlService.parseFlightId(params);
const transformedDate = this.dateService.toString(date);
return this.translatePipe.transform(`SEO.BOARD.FLIGHT-DETAILS.${tag}`, {
flightNumber: `${carrier} ${flightNumber}`,
date: transformedDate,
});
}
protected translateTitle(params: string): string {
return this.translate('TITLE', params);
}
protected translateDescription(params: string): string {
return this.translate('DESCRIPTION', params);
}
}
@@ -0,0 +1 @@
<aero-title [title]="title"></aero-title>
@@ -0,0 +1,146 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslatePipe } from '@ngx-translate/core';
import { OnlineBoardFlightDetailsTitleComponent } from '@online-board/pages/flight-details/components/title/flight-details-title.component';
import { OnlineBoardUrlBuilderService } from '@online-board/services/url';
import { RouteType } from '@typings/enums';
import { IFlightId } from '@typings/flight/flight-id';
describe('OnlineBoardFlightDetailsTitleComponent', () => {
let fixture: ComponentFixture<OnlineBoardFlightDetailsTitleComponent>;
let component: OnlineBoardFlightDetailsTitleComponent;
const firstFlightId: IFlightId = {
flightNumber: '0001',
carrier: 'SU',
date: '2022-05-28T00:00:00',
};
const secondFlightId: IFlightId = {
flightNumber: '0002',
carrier: 'SU',
date: '2022-05-28T00:00:00',
};
const titleBase = 'Информация о рейсе';
const pluralTitleBase = 'Информация о рейсах';
const transformMock = jasmine
.createSpy('transform')
.withArgs('SHARED.FLIGHT-INFO')
.and.returnValue(titleBase)
.withArgs('SHARED.FLIGHTS-INFO')
.and.returnValue(pluralTitleBase);
const formatFlightIdMock = jasmine
.createSpy('formatFlightId')
.withArgs(firstFlightId)
.and.returnValue('SU 0001')
.withArgs(secondFlightId)
.and.returnValue('SU 0002');
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [OnlineBoardFlightDetailsTitleComponent],
providers: [
{
provide: TranslatePipe,
useValue: {
transform: transformMock,
},
},
{
provide: OnlineBoardUrlBuilderService,
useValue: {
formatFlightId: formatFlightIdMock,
},
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(
OnlineBoardFlightDetailsTitleComponent,
);
component = fixture.componentInstance;
transformMock.calls.reset();
formatFlightIdMock.calls.reset();
});
it('should set empty string to title if flight was not provided', () => {
component.ngOnChanges();
expect(component.title).toBe('');
});
it('should set title for non connecting flight', () => {
component.flight = {
routeType: RouteType.DIRECT,
flightId: firstFlightId,
leg: {
departure: {
latest: {
city: 'Москва',
},
},
arrival: {
latest: {
city: 'Самара',
},
},
},
} as any;
component.ngOnChanges();
expect(component.title).toBe(
'Информация о рейсе: SU 0001, Москва - Самара',
);
});
it('should set title for connecting flight', () => {
component.flight = {
routeType: RouteType.CONNECTING,
flights: [
{
routeType: RouteType.DIRECT,
flightId: firstFlightId,
leg: {
departure: {
latest: {
city: 'Москва',
},
},
arrival: {
latest: {
city: 'Самара',
},
},
},
},
{
routeType: RouteType.DIRECT,
flightId: secondFlightId,
leg: {
departure: {
latest: {
city: 'Самара',
},
},
arrival: {
latest: {
city: 'Казань',
},
},
},
},
],
} as any;
component.ngOnChanges();
expect(component.title).toBe(
'Информация о рейсах: SU 0001, SU 0002, Москва - Казань',
);
});
});
@@ -0,0 +1,33 @@
import { Component } from '@angular/core';
import { DetailsTitleBase } from '@components/title-base/details-title-base';
import { TranslatePipe } from '@ngx-translate/core';
import { OnlineBoardUrlBuilderService } from '@online-board/services/url/url-builder.service';
@Component({
selector: 'online-board-flight-details-title',
templateUrl: './flight-details-title.component.html',
})
export class OnlineBoardFlightDetailsTitleComponent extends DetailsTitleBase {
constructor(
protected translatePipe: TranslatePipe,
protected urlService: OnlineBoardUrlBuilderService,
) {
super(translatePipe, urlService);
}
protected get titleKey() {
return 'SHARED.FLIGHT-INFO';
}
protected get pluralTitleKey() {
return 'SHARED.FLIGHTS-INFO';
}
protected translateTitle(): string {
return `${this.base}: ${this.flightInfo}, ${this.departure} - ${this.arrival}`;
}
protected translatePluralTitle() {
return this.translateTitle();
}
}
@@ -0,0 +1,79 @@
<div *ngIf="flightLegacy">
<page-layout scrollUp [withScrollUp]="false">
<ng-container title>
<ng-content select="[title]"></ng-content>
</ng-container>
<details-back
header-left
class="p-print-none"
[viewType]="ViewType.Onlineboard"
></details-back>
<online-board-flights-mini-list
content-left
class="p-print-none"
(open)="handleOpenEvent($any($event))"
[flights]="flightsLegacy"
[flight]="flightLegacy"
></online-board-flights-mini-list>
<card [roundedTop]="true" sticky-content>
<day-tabs
[flight]="flightLegacy"
class="board-details-day-selector"
caption="SHARED.FLIGHT_DATE"
[selectedDate]="flightLegacy.dateToSearchBy"
[dateTo]="settings.boardMaxDate"
[dateFrom]="settings.boardMinDate"
[minDate]="settings.boardMinDate"
[maxDate]="settings.boardMaxDate"
(tabClick)="handleDateChanged($event)"
></day-tabs>
<board-details-header
[flight]="flightLegacy"
></board-details-header>
</card>
<ng-container>
<page-loader *ngIf="loading" [canCancel]="false"></page-loader>
<ng-container *ngIf="!loading">
<div [ngSwitch]="flightLegacy.routeType">
<div class="single-flight" *ngSwitchCase="RouteType.Direct">
<flight-board-details
[flight]="flightLegacy"
[leg]="flightLegacy.leg"
></flight-board-details>
</div>
<div
class="multi-flight"
*ngSwitchCase="RouteType.MultiLeg"
>
<flight-details-full-route
[viewType]="ViewType.Onlineboard"
[legs]="flightLegacy.legs"
></flight-details-full-route>
<!--aka Timeline by KS-->
<ng-container
*ngFor="let leg of flightLegacy.legs; let i = index"
>
<flight-board-details
[flight]="flightLegacy"
[leg]="leg"
></flight-board-details>
<transfer
[leg]="leg"
[viewType]="ViewType.Onlineboard"
*ngIf="leg.next"
>
</transfer>
</ng-container>
</div>
</div>
<section class="frame">
<flight-schedule [flight]="flightLegacy"></flight-schedule>
</section>
</ng-container>
</ng-container>
</page-layout>
</div>
@@ -0,0 +1,28 @@
@use 'src/styles/variables' as *;
@use 'src/styles/screen';
:host {
::ng-deep .flight-props-caption {
width: 30%;
}
// todo: remove after removing all usages of flight-props component
::ng-deep .flight-details-section__caption {
width: calc(30% - #{$space-xl}) !important;
}
online-board-flights-mini-list {
@include screen.smTablet {
display: none;
}
}
.board-details-day-selector {
@include screen.mobile {
display: flex;
flex-direction: column;
padding: $space-xl;
}
}
}
@@ -0,0 +1,100 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OnlineBoardDetailsViewComponent } from '@online-board/pages/flight-details/components/view/online-board-details.component';
import DeprecatedFlightUtils from '@shared/models-legacy/get-flight.strategy';
import { APP_SETTINGS } from '@shared/services';
import { RouteType } from '@typings/enums';
describe('OnlineBoardDetailsViewComponent', () => {
let fixture: ComponentFixture<OnlineBoardDetailsViewComponent>;
let component: OnlineBoardDetailsViewComponent;
let getFirstLegMock;
const emitOpenMock = jasmine.createSpy('open.emit');
const emitDateChangeMock = jasmine.createSpy('dateChange.emit');
const flightLegacy: any = {
routeType: RouteType.DIRECT,
pId: '1',
flightId: {
flightNumber: '0001',
carrier: 'SU',
date: '2022-05-28T00:00:00',
},
leg: {
departure: {
latest: {
city: 'Москва',
},
},
arrival: {
latest: {
city: 'Самара',
},
},
},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [OnlineBoardDetailsViewComponent],
providers: [
{
provide: APP_SETTINGS,
useValue: {},
},
],
schemas: [NO_ERRORS_SCHEMA],
});
});
beforeEach(() => {
fixture = TestBed.createComponent(OnlineBoardDetailsViewComponent);
component = fixture.componentInstance;
getFirstLegMock = spyOn(DeprecatedFlightUtils, 'getFirstLeg')
.withArgs(flightLegacy)
.and.returnValue(flightLegacy.leg as any);
emitOpenMock.calls.reset();
emitDateChangeMock.calls.reset();
component.flightLegacy = flightLegacy;
(component.open as any) = {
emit: emitOpenMock,
};
(component.dateChange as any) = {
emit: emitDateChangeMock,
};
});
it('should not call getFirstLeg from utils', () => {
component.ngOnChanges({});
expect(getFirstLegMock).not.toHaveBeenCalled();
});
it('should set firstLeg', () => {
component.ngOnChanges({
flight: {} as any,
});
expect(component.firstLeg).toEqual(flightLegacy.leg);
});
it('should not emit open event because pIds are equal', () => {
component.handleOpenEvent({ pId: '1' } as any);
expect(emitOpenMock).not.toHaveBeenCalled();
});
it('should emit open event with provided flight', () => {
const flight = { pId: '2' } as any;
component.handleOpenEvent(flight);
expect(emitOpenMock).toHaveBeenCalledWith(flight);
});
it('should emit date', () => {
const date = new Date(2022, 4, 28);
component.handleDateChanged(date);
expect(emitDateChangeMock).toHaveBeenCalledWith(date);
});
});
@@ -0,0 +1,59 @@
import {
Component,
EventEmitter,
Inject,
Input,
OnChanges,
Output,
SimpleChanges,
} from '@angular/core';
import { RouteTypeLegacy } from '@shared/enumerators';
import { ViewType } from '@shared/enumerators/flight-request-type.enum';
import { AppSettings, FlightModel, Leg } from '@shared/models-legacy';
import DeprecatedFlightUtils from '@shared/models-legacy/get-flight.strategy';
import { APP_SETTINGS } from '@shared/services';
import { ISimpleFlight } from '@typings/flight/flight';
@Component({
selector: 'online-board-details-view',
templateUrl: './online-board-details.component.html',
styleUrls: ['./online-board-details.component.scss'],
})
export class OnlineBoardDetailsViewComponent implements OnChanges {
@Input() flightLegacy: FlightModel;
@Input() flightsLegacy: FlightModel[];
@Input() flight: ISimpleFlight;
@Input() loading = false;
@Input() searchDate: Date;
@Output() dateChange = new EventEmitter<Date>();
@Output() open = new EventEmitter<ISimpleFlight>();
ViewType = ViewType;
RouteType = RouteTypeLegacy;
firstLeg: Leg;
constructor(@Inject(APP_SETTINGS) public settings: AppSettings) {}
ngOnChanges(changes: SimpleChanges) {
if ('flight' in changes) {
this.firstLeg = DeprecatedFlightUtils.getFirstLeg(
this.flightLegacy,
);
}
}
handleOpenEvent(flight: ISimpleFlight) {
// Click on the currently opened flight in the list will cause
// infinite loading state
if (this.flightLegacy.id === flight.id) {
return;
}
this.open.emit(flight);
}
handleDateChanged(date: Date) {
this.dateChange.emit(date);
}
}
@@ -0,0 +1,15 @@
<online-board-flight-details-meta-tags></online-board-flight-details-meta-tags>
<online-board-details-view
[flightLegacy]="dataSource.flightLegacy"
[flightsLegacy]="dataSource.flightsLegacy"
[flight]="dataSource.flight"
[loading]="dataSource.loading"
[searchDate]="searchDate"
(open)="handleOpenEvent($event)"
(dateChange)="handleDateChange($event)"
>
<online-board-flight-details-title
title
[flight]="dataSource.flight"
></online-board-flight-details-title>
</online-board-details-view>
@@ -0,0 +1,262 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { OnlineBoardFlightDetailsPageComponent } from '@online-board/pages/flight-details/flight-details-page.component';
import { RefreshService } from '@online-board/pages/flight-details/services/refresh.service';
import { OnlineBoardDataSourceService } from '@online-board/services/data-source.service';
import { OnlineBoardNavigationService } from '@online-board/services/navigation.service';
import { IOnlineBoardRouteData } from '@online-board/types';
import { IFlightId } from '@typings/flight/flight-id';
import { BehaviorSubject } from 'rxjs';
describe('OnlineBoardFlightDetailsPageComponent', () => {
let fixture: ComponentFixture<OnlineBoardFlightDetailsPageComponent>;
let component: OnlineBoardFlightDetailsPageComponent;
const data: IOnlineBoardRouteData = {};
const loadFlightByFlightNumberMock = jasmine.createSpy(
'loadFlightByFlightNumber',
);
const loadFlightsByRouteMock = jasmine.createSpy('loadFlightsByRoute');
const loadFlightsByFlightNumberMock = jasmine.createSpy(
'loadFlightsByFlightNumber',
);
const refreshStartMock = jasmine.createSpy('start');
const refreshStopMock = jasmine.createSpy('stop');
const toDetailsPageMock = jasmine.createSpy('toDetailsPage');
const toNotFoundMock = jasmine.createSpy('toNotFound');
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [OnlineBoardFlightDetailsPageComponent],
providers: [
{
provide: OnlineBoardDataSourceService,
useValue: {
loadFlightByFlightNumber: loadFlightByFlightNumberMock,
loadFlightsByRoute: loadFlightsByRouteMock,
loadFlightsByFlightNumber:
loadFlightsByFlightNumberMock,
},
},
{
provide: ActivatedRoute,
useValue: {
data: new BehaviorSubject(data),
},
},
{
provide: OnlineBoardNavigationService,
useValue: {
toDetailsPage: toDetailsPageMock,
toNotFound: toNotFoundMock,
},
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
TestBed.overrideComponent(OnlineBoardFlightDetailsPageComponent, {
set: {
providers: [
{
provide: RefreshService,
useValue: {
start: refreshStartMock,
stop: refreshStopMock,
},
},
],
},
});
});
beforeEach(() => {
fixture = TestBed.createComponent(
OnlineBoardFlightDetailsPageComponent,
);
component = fixture.componentInstance;
loadFlightByFlightNumberMock.calls.reset();
loadFlightsByFlightNumberMock.calls.reset();
loadFlightsByRouteMock.calls.reset();
refreshStartMock.calls.reset();
refreshStopMock.calls.reset();
toDetailsPageMock.calls.reset();
toNotFoundMock.calls.reset();
loadFlightByFlightNumberMock.and.returnValue(Promise.resolve());
data.urlParams = {
details: {
flightNumber: '0001',
carrier: 'SU',
date: new Date(2022, 4, 28),
},
};
data.requestData = {
raw: 'onlineboard-arrival-MOW-20220528',
parsed: {
type: 'route',
params: {
arrival: 'MOW',
date: new Date(2022, 4, 27),
},
},
};
});
it('should not call loadFlightByFlightNumber and store params', () => {
data.urlParams.details = undefined;
component.ngOnInit();
expect(component.params).toBe(undefined);
expect(loadFlightByFlightNumberMock).not.toHaveBeenCalled();
data.urlParams = undefined;
component.ngOnInit();
expect(component.params).toBe(undefined);
expect(loadFlightByFlightNumberMock).not.toHaveBeenCalled();
});
it('should not call dataSource methods and store request', () => {
data.urlParams.details = undefined;
data.requestData = undefined;
component.ngOnInit();
expect(component.requestQueryParam).toBe(undefined);
expect(loadFlightByFlightNumberMock).not.toHaveBeenCalled();
expect(loadFlightsByRouteMock).not.toHaveBeenCalled();
});
it('should call loadFlightByFlightNumber and store params', () => {
data.requestData = undefined;
component.ngOnInit();
expect(component.params).toEqual(data.urlParams.details);
expect(loadFlightByFlightNumberMock).toHaveBeenCalledWith(
data.urlParams.details,
);
});
it('should start refresh service', async () => {
loadFlightByFlightNumberMock.and.returnValue(
Promise.resolve({
data: {
routes: ['1'],
},
}),
);
data.requestData = undefined;
component.ngOnInit();
await Promise.resolve();
await Promise.resolve();
expect(refreshStartMock).toHaveBeenCalledWith(data.urlParams.details);
});
it('should navigate to not found if loadFlightByFlightNumber failed', async () => {
data.requestData = undefined;
loadFlightByFlightNumberMock.and.returnValue(Promise.reject());
component.ngOnInit();
await Promise.resolve();
await Promise.resolve();
expect(toNotFoundMock).toHaveBeenCalled();
});
it('should navigate to not found if api request resolved with empty result', async () => {
loadFlightByFlightNumberMock.and.returnValue(
Promise.resolve({
data: {
routes: [],
},
}),
);
component.ngOnInit();
await Promise.resolve();
expect(refreshStartMock).not.toHaveBeenCalled();
expect(toNotFoundMock).toHaveBeenCalled();
});
it('should stop refresh service', () => {
component.ngOnDestroy();
expect(refreshStopMock).toHaveBeenCalled();
});
it('should call loadFlightsByRoute and store request', () => {
component.ngOnInit();
expect(component.requestQueryParam).toBe(data.requestData.raw);
expect(loadFlightsByRouteMock).toHaveBeenCalledWith(
data.requestData.parsed.params,
);
});
it('should call loadFlightsByFlightNumber and store request', () => {
data.requestData = {
raw: 'onlineboard-flight-SU0001-20220528',
parsed: {
type: 'flight-number',
params: {
flightNumber: '0001',
carrier: 'SU',
date: new Date(2022, 4, 28),
},
},
};
component.ngOnInit();
expect(component.requestQueryParam).toBe(data.requestData.raw);
expect(loadFlightsByFlightNumberMock).toHaveBeenCalledWith(
data.requestData.parsed.params,
);
});
it('should return stored date', () => {
component.ngOnInit();
expect(component.searchDate).toEqual(data.urlParams.details.date);
});
it('should navigate to details with stored data and provided date', () => {
const date = new Date(2022, 4, 29);
const flightId: IFlightId = {
date: '2022-05-28T00:00:00',
flightNumber: '0001',
carrier: 'SU',
};
(component.dataSource as any).flight = { flightId };
component.ngOnInit();
component.handleDateChange(date);
expect(toDetailsPageMock).toHaveBeenCalledWith({
flightId,
date,
request: data.requestData.raw,
});
});
it('should navigate to details with stored data and provided flight', () => {
const flightId: IFlightId = {
date: '2022-05-25T00:00:00',
flightNumber: '0001',
carrier: 'SU',
};
const flight: any = { flightId };
component.ngOnInit();
component.handleOpenEvent(flight);
expect(toDetailsPageMock).toHaveBeenCalledWith({
flightId,
date: new Date(2022, 4, 25),
request: data.requestData.raw,
});
});
});
@@ -0,0 +1,98 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { OnlineBoardDataSourceService } from '@online-board/services/data-source.service';
import { OnlineBoardNavigationService } from '@online-board/services/navigation.service';
import { IOnlineBoardRequest } from '@online-board/services/request/request-parser.service';
import { IOnlineBoardRouteData } from '@online-board/types';
import { ISimpleFlight } from '@typings/flight/flight';
import { IFlightId, IParsedFlightId } from '@typings/flight/flight-id';
import { RefreshService } from './services/refresh.service';
import FlightDateUtils from '@utils/flight/date';
@Component({
selector: 'online-board-flight-details-page',
templateUrl: './flight-details-page.component.html',
providers: [RefreshService],
})
export class OnlineBoardFlightDetailsPageComponent
implements OnInit, OnDestroy
{
params: IParsedFlightId;
requestQueryParam: string;
constructor(
public dataSource: OnlineBoardDataSourceService,
private route: ActivatedRoute,
private refreshService: RefreshService,
private navigationService: OnlineBoardNavigationService,
) {}
ngOnInit(): void {
this.route.data.subscribe({
next: (data: IOnlineBoardRouteData) => {
if (data.urlParams?.details) {
this.handleUrlParamsChange(data.urlParams.details);
}
if (data.requestData) {
this.requestQueryParam = data.requestData.raw;
this.loadFlightsByRequest(data.requestData.parsed);
}
},
});
}
ngOnDestroy() {
this.refreshService.stop();
}
get searchDate() {
return this.params.date;
}
handleDateChange(date: Date) {
this.navigationService.toDetailsPage({
flightId: this.dataSource.flight.flightId,
date,
request: this.requestQueryParam,
});
}
handleOpenEvent(flight: ISimpleFlight) {
this.navigationService.toDetailsPage({
flightId: flight.flightId,
date: FlightDateUtils.getFlightDate(flight),
request: this.requestQueryParam,
});
}
private handleUrlParamsChange(params: IParsedFlightId) {
this.params = params;
this.dataSource
.loadFlightByFlightNumber(this.params)
.then((res) => {
if (!res.data.routes.length) {
return this.navigationService.toNotFound();
}
this.refreshService.start(res.data.routes[0].flightId);
})
.catch(() => {
return this.navigationService.toNotFound();
});
}
private loadFlightsByRequest(request: IOnlineBoardRequest) {
switch (request.type) {
case 'route': {
this.dataSource.loadFlightsByRoute(request.params);
return;
}
case 'flight-number': {
this.dataSource.loadFlightsByFlightNumber(request.params);
return;
}
}
}
}
@@ -0,0 +1,121 @@
import { TestBed } from '@angular/core/testing';
import { RefreshService } from '@online-board/pages/flight-details/services/refresh.service';
import { OnlineBoardDataSourceService } from '@online-board/services/data-source.service';
import { APP_SETTINGS } from '@shared/services';
import { FlightStatus } from '@typings/enums';
import { IParsedFlightId } from '@typings/flight/flight-id';
describe('RefreshService', () => {
let service: RefreshService;
let dataSource: OnlineBoardDataSourceService;
const refreshInterval = 100;
const unsubscribeMock = jasmine.createSpy('unsubscribe');
const loadFlightByFlightNumberMock = jasmine.createSpy(
'loadFlightByFlightNumber',
);
const params: IParsedFlightId = {
flightNumber: '0001',
carrier: 'SU',
date: new Date(2022, 4, 28),
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
RefreshService,
{
provide: APP_SETTINGS,
useValue: {
refreshInterval,
},
},
{
provide: OnlineBoardDataSourceService,
useValue: {
loadFlightByFlightNumber: loadFlightByFlightNumberMock,
},
},
],
});
unsubscribeMock.calls.reset();
loadFlightByFlightNumberMock.calls.reset();
service = TestBed.inject(RefreshService);
dataSource = TestBed.inject(OnlineBoardDataSourceService);
});
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should call loadFlightByFlightNumber with provided params each 100 seconds', () => {
service.start(params);
jasmine.clock().tick(refreshInterval);
expect(loadFlightByFlightNumberMock).toHaveBeenCalledWith(params, true);
jasmine.clock().tick(refreshInterval);
expect(loadFlightByFlightNumberMock).toHaveBeenCalledTimes(2);
jasmine.clock().tick(refreshInterval);
expect(loadFlightByFlightNumberMock).toHaveBeenCalledTimes(3);
});
it('should unsubscribe if there is running interval', () => {
(service.updateInterval as any) = {
unsubscribe: unsubscribeMock,
};
service.start(params);
expect(unsubscribeMock).toHaveBeenCalled();
});
it('should unsubscribe', () => {
expect(() => service.stop()).not.toThrowError();
(service.updateInterval as any) = {
unsubscribe: unsubscribeMock,
};
service.stop();
expect(unsubscribeMock).toHaveBeenCalled();
});
it('should call loadFlightByFlightNumber because flight has appropriate status', () => {
(dataSource.flight as any) = { status: FlightStatus.IN_FLIGHT };
service.start(params);
jasmine.clock().tick(refreshInterval);
expect(loadFlightByFlightNumberMock).toHaveBeenCalledWith(params, true);
service.stop();
(dataSource.flight as any) = { status: FlightStatus.SENT };
service.start(params);
jasmine.clock().tick(refreshInterval);
expect(loadFlightByFlightNumberMock).toHaveBeenCalledTimes(2);
const [first, second] =
loadFlightByFlightNumberMock.calls.mostRecent().args;
expect(first).toEqual(params);
expect(second).toBe(true);
service.stop();
});
it('should not call loadFlightByFlightNumber because flight has not appropriate status', () => {
(dataSource.flight as any) = { status: FlightStatus.CANCELLED };
service.start(params);
jasmine.clock().tick(refreshInterval);
expect(loadFlightByFlightNumberMock).not.toHaveBeenCalled();
service.stop();
});
});
@@ -0,0 +1,58 @@
import { Inject, Injectable } from '@angular/core';
import * as signalR from "@microsoft/signalr";
import { environment } from '@environment';
import { AppSettings } from '@shared/models-legacy';
import { APP_SETTINGS } from '@shared/services';
import { FlightStatus } from '@typings/enums';
import { OnlineBoardDataSourceService } from '@online-board/services/data-source.service';
import { IFlightId, IParsedFlightId } from '@typings/flight/flight-id';
import { FadeService } from '@online-board/services/fade.service';
@Injectable()
export class RefreshService {
connection: any;
constructor(
@Inject(APP_SETTINGS) private settings: AppSettings,
public dataSource: OnlineBoardDataSourceService,
private fadeService: FadeService,
) {}
init() {
this.connection = new signalR.HubConnectionBuilder()
.withUrl(environment.urlForTrackerHub)
.withAutomaticReconnect()
.build();
this.connection.on('Refresh', (flightId: string) => {
const l = flightId.length;
const params: IParsedFlightId = {
carrier: flightId.substring(0,2),
flightNumber: flightId.substring(2,6),
suffix: flightId.substring(6,l-11),
date: new Date(flightId.substring(l-10, l)),
};
this.dataSource.loadFlightByFlightNumber(params, true).finally(() => {
});;
});
}
start(params: IFlightId) {
if (!this.connection) {
this.init();
}
// if connection is disconnected then stop just console.log about it and returns, then we start it
this.connection.stop().then(() => {
this.fadeService.start();
this.connection.start().then(() => {
this.connection.invoke("Subscribe", params.carrier+params.flightNumber+params.suffix+"@"+params.dateLT);
});
});
}
stop() {
this.connection.stop();
this.fadeService.stop();
}
}
@@ -0,0 +1,4 @@
<online-board-arrival-meta-tags></online-board-arrival-meta-tags>
<online-board-search-page-base [requestType]="FlightRequestType.Arrival">
<online-board-arrival-title title></online-board-arrival-title>
</online-board-search-page-base>
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { FlightRequestType } from '@shared/enumerators/flight-request-type.enum';
@Component({
selector: 'arrival-search-page',
templateUrl: 'arrival-search-page.component.html',
})
export class ArrivalSearchPageComponent {
FlightRequestType = FlightRequestType;
}
@@ -0,0 +1 @@
<meta-tags [title]="title" [description]="description"></meta-tags>
@@ -0,0 +1,71 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { OnlineBoardArrivalMetaTagsComponent } from '@online-board/pages/search/arrival/components/meta-tags/online-board-arrival-meta-tags.component';
import { OnlineBoardMetaTagsService } from '@online-board/services/meta-tags.service';
import { IOnlineBoardRouteData } from '@online-board/types';
import { BehaviorSubject } from 'rxjs';
describe('OnlineBoardArrivalMetaTagsComponent', () => {
let component: OnlineBoardArrivalMetaTagsComponent;
let fixture: ComponentFixture<OnlineBoardArrivalMetaTagsComponent>;
const getArrivalMetaTagsMock = jasmine.createSpy('getArrivalMetaTags');
const data: IOnlineBoardRouteData = {
urlParams: {},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [OnlineBoardArrivalMetaTagsComponent],
providers: [
{
provide: OnlineBoardMetaTagsService,
useValue: {
getArrivalMetaTags: getArrivalMetaTagsMock,
},
},
{
provide: ActivatedRoute,
useValue: {
data: new BehaviorSubject(data),
},
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(OnlineBoardArrivalMetaTagsComponent);
component = fixture.componentInstance;
data.urlParams.route = undefined;
getArrivalMetaTagsMock.calls.reset();
});
it('should not set title and description because route param is not provided', () => {
component.ngOnInit();
expect(component.title).toBe('');
expect(component.description).toBe('');
});
it('should set title and description', async () => {
data.urlParams.route = {
arrival: 'MOW',
date: new Date(2022, 4, 28),
};
getArrivalMetaTagsMock.withArgs(data.urlParams.route).and.returnValue(
Promise.resolve({
title: 'arrival title',
description: 'arrival description',
}),
);
component.ngOnInit();
await Promise.resolve();
expect(component.title).toBe('arrival title');
expect(component.description).toBe('arrival description');
});
});
@@ -0,0 +1,31 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { OnlineBoardMetaTagsService } from '@online-board/services/meta-tags.service';
import { IOnlineBoardRouteData } from '@online-board/types';
@Component({
selector: 'online-board-arrival-meta-tags',
templateUrl: './online-board-arrival-meta-tags.component.html',
})
export class OnlineBoardArrivalMetaTagsComponent implements OnInit {
title = '';
description = '';
constructor(
private route: ActivatedRoute,
private metaTagsService: OnlineBoardMetaTagsService,
) {}
ngOnInit() {
this.route.data.subscribe(async (data: IOnlineBoardRouteData) => {
if (data.urlParams.route) {
const metaTags = await this.metaTagsService.getArrivalMetaTags(
data.urlParams.route,
);
this.title = metaTags.title;
this.description = metaTags.description;
}
});
}
}
@@ -0,0 +1 @@
<aero-title [title]="title"></aero-title>
@@ -0,0 +1,66 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { OnlineBoardArrivalTitleComponent } from '@online-board/pages/search/arrival/components/title/online-board-arrival-title.component';
import { OnlineBoardTitleService } from '@online-board/services/title.service';
import { IOnlineBoardRouteData } from '@online-board/types';
import { BehaviorSubject } from 'rxjs';
describe('OnlineBoardArrivalTitleComponent', () => {
let component: OnlineBoardArrivalTitleComponent;
let fixture: ComponentFixture<OnlineBoardArrivalTitleComponent>;
const getArrivalTitleMock = jasmine.createSpy('getArrivalTitle');
const data: IOnlineBoardRouteData = {
urlParams: {},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [OnlineBoardArrivalTitleComponent],
providers: [
{
provide: OnlineBoardTitleService,
useValue: {
getArrivalTitle: getArrivalTitleMock,
},
},
{
provide: ActivatedRoute,
useValue: {
data: new BehaviorSubject(data),
},
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(OnlineBoardArrivalTitleComponent);
component = fixture.componentInstance;
data.urlParams.route = undefined;
getArrivalTitleMock.calls.reset();
});
it('should not set title because route param is not provided', () => {
component.ngOnInit();
expect(component.title).toBe('');
});
it('should set title', async () => {
data.urlParams.route = {
arrival: 'MOW',
date: new Date(2022, 4, 28),
};
getArrivalTitleMock
.withArgs(data.urlParams.route)
.and.returnValue(Promise.resolve('arrival title'));
component.ngOnInit();
await Promise.resolve();
expect(component.title).toBe('arrival title');
});
});
@@ -0,0 +1,29 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { OnlineBoardTitleService } from '@online-board/services/title.service';
import { IOnlineBoardRouteData } from '@online-board/types';
@Component({
selector: 'online-board-arrival-title',
templateUrl: './online-board-arrival-title.component.html',
})
export class OnlineBoardArrivalTitleComponent implements OnInit {
title = '';
constructor(
private route: ActivatedRoute,
private titleService: OnlineBoardTitleService,
) {}
ngOnInit() {
this.route.data.subscribe({
next: async (data: IOnlineBoardRouteData) => {
if (data.urlParams.route) {
this.title = await this.titleService.getArrivalTitle(
data.urlParams.route,
);
}
},
});
}
}
@@ -0,0 +1,21 @@
<same-url-navigation-detector
(sameUrlNavigation)="handleSameUrlNavigation()"
></same-url-navigation-detector>
<online-board-search
[flights]="dataSource.flightsLegacy"
[loading]="dataSource.loading"
[partners]="dataSource.partners"
[searchDate]="params.date"
[timeFrom]="params.timeFrom"
[timeTo]="params.timeTo"
[requestType]="requestType"
[disabledDates]="disabledDates"
(requestCancel)="handleRequestCancellation()"
(searchDateChange)="handleDateChange($event)"
(timeRangeChange)="handleTimeRangeChange($event)"
(toDetails)="navigateToDetails($any($event))"
>
<ng-container title>
<ng-content select="[title]"></ng-content>
</ng-container>
</online-board-search>
@@ -0,0 +1,185 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { OnlineBoardSearchPageBaseComponent } from '@online-board/pages/search/components/base/search-page-base.component';
import { OnlineBoardDataSourceService } from '@online-board/services/data-source.service';
import { OnlineBoardNavigationService } from '@online-board/services/navigation.service';
import { OnlineBoardRequestBuilderService } from '@online-board/services/request/request-builder.service';
import { IOnlineBoardRouteData } from '@online-board/types';
import { IUrlTimeRange } from '@typings/common/url';
import { IFlightId } from '@typings/flight/flight-id';
import FlightDateUtils from '@utils/flight/date';
import { BehaviorSubject } from 'rxjs';
describe('OnlineBoardSearchPageBaseComponent', () => {
let fixture: ComponentFixture<OnlineBoardSearchPageBaseComponent>;
let component: OnlineBoardSearchPageBaseComponent;
let getFlightDateMock;
const data: IOnlineBoardRouteData = {};
const loadFlightsByRouteMock = jasmine.createSpy('loadFlightsByRoute');
const stopLoadingMock = jasmine.createSpy('stopLoading');
const toStartPageMock = jasmine.createSpy('toStartPage');
const toRoutePageMock = jasmine.createSpy('toRoutePage');
const toDetailsPageMock = jasmine.createSpy('toDetailsPage');
const buildRouteRequestMock = jasmine.createSpy('buildRouteRequest');
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [OnlineBoardSearchPageBaseComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
data: new BehaviorSubject(data),
},
},
{
provide: OnlineBoardDataSourceService,
useValue: {
loadFlightsByRoute: loadFlightsByRouteMock,
stopLoading: stopLoadingMock,
},
},
{
provide: OnlineBoardNavigationService,
useValue: {
toStartPage: toStartPageMock,
toRoutePage: toRoutePageMock,
toDetailsPage: toDetailsPageMock,
},
},
{
provide: OnlineBoardRequestBuilderService,
useValue: {
buildRouteRequest: buildRouteRequestMock,
},
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(OnlineBoardSearchPageBaseComponent);
component = fixture.componentInstance;
getFlightDateMock = spyOn(FlightDateUtils, 'getFlightDate');
loadFlightsByRouteMock.calls.reset();
stopLoadingMock.calls.reset();
toRoutePageMock.calls.reset();
toStartPageMock.calls.reset();
toDetailsPageMock.calls.reset();
buildRouteRequestMock.calls.reset();
data.urlParams = {
route: {
date: new Date(2022, 4, 28),
arrival: 'MRS',
departure: 'MOW',
},
};
});
it('should not call dataSource.loadFlightsByRoute if no params were provided', () => {
data.urlParams.route = undefined;
component.ngOnInit();
expect(component.params).toBe(undefined);
expect(loadFlightsByRouteMock).not.toHaveBeenCalled();
data.urlParams = undefined;
component.ngOnInit();
expect(component.params).toBe(undefined);
expect(loadFlightsByRouteMock).not.toHaveBeenCalled();
});
it('should call dataSource.loadFlightsByRoute', () => {
component.ngOnInit();
expect(component.params).toBe(data.urlParams.route);
expect(loadFlightsByRouteMock).toHaveBeenCalledWith(
data.urlParams.route,
);
});
it('should call dataSource.loadFlightsByRoute with params from urlParams.route', () => {
component.ngOnInit();
expect(component.params).toBe(data.urlParams.route);
component.handleSameUrlNavigation();
expect(loadFlightsByRouteMock.calls.count()).toBe(2);
expect(loadFlightsByRouteMock.calls.mostRecent().args[0]).toEqual(
data.urlParams.route,
);
});
it('should call dataSource.stopLoading and navigate to start page', () => {
component.handleRequestCancellation();
expect(stopLoadingMock).toHaveBeenCalled();
expect(toStartPageMock).toHaveBeenCalled();
});
it('should navigate to route page with saved params and provided date', () => {
const date = new Date(2022, 5, 28);
component.ngOnInit();
component.handleDateChange(date);
expect(toRoutePageMock).toHaveBeenCalledWith({
...data.urlParams.route,
date,
});
});
it('should navigate to route page with saved params and provided time range', () => {
const timeRange: IUrlTimeRange = {
timeFrom: '0000',
timeTo: '1800',
};
component.ngOnInit();
component.handleTimeRangeChange(timeRange);
expect(toRoutePageMock).toHaveBeenCalledWith({
...data.urlParams.route,
...timeRange,
});
component.handleTimeRangeChange();
expect(toRoutePageMock).toHaveBeenCalledWith({
...data.urlParams.route,
timeFrom: undefined,
timeTo: undefined,
});
});
it('should navigate to details with flights info and request built from saved params', () => {
const request = 'onlineboard-route-MOW-MRS-20220528';
const dateStr = '2022-05-27T00:00:00';
const date = new Date(dateStr);
const flightId: IFlightId = {
flightNumber: '0001',
carrier: 'SU',
date: dateStr,
};
getFlightDateMock.withArgs({ flightId }).and.returnValue(date);
buildRouteRequestMock
.withArgs(data.urlParams.route)
.and.returnValue(request);
component.ngOnInit();
component.navigateToDetails({ flightId } as any);
expect(toDetailsPageMock).toHaveBeenCalledWith({
flightId,
date,
request,
});
});
});
@@ -0,0 +1,118 @@
import { Component, Input, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { OnlineBoardDataSourceService } from '@online-board/services/data-source.service';
import { OnlineBoardNavigationService } from '@online-board/services/navigation.service';
import { OnlineBoardRequestBuilderService } from '@online-board/services/request/request-builder.service';
import { IOnlineBoardRoutePageUrlParams } from '@online-board/services/url/types';
import { OnlineBoardApiService } from '@online-board/services/api.service';
import { OnlineBoardRouteFilterValidationService } from '@online-board/components/filter/components/route-filter/services/online-board-route-filter-validation.service';
import { FlightRequestType } from '@shared/enumerators/flight-request-type.enum';
import { IUrlTimeRange } from '@typings/common/url';
import { ISimpleFlight } from '@typings/flight/flight';
import { IOnlineBoardRouteData } from '@online-board/types';
import { RefreshBoardService } from './services/refresh-board.service';
import { ApiFormatterService } from '@shared/services/api/formatter.service';
import FlightDateUtils from '@utils/flight/date';
@Component({
selector: 'online-board-search-page-base',
templateUrl: './search-page-base.component.html',
providers: [OnlineBoardRouteFilterValidationService, RefreshBoardService],
})
export class OnlineBoardSearchPageBaseComponent implements OnInit {
@Input() requestType: FlightRequestType;
disabledDates: Date[];
params: IOnlineBoardRoutePageUrlParams;
constructor(
public dataSource: OnlineBoardDataSourceService,
private apiService: OnlineBoardApiService,
private apiFormatter: ApiFormatterService,
private validationService: OnlineBoardRouteFilterValidationService,
private route: ActivatedRoute,
private refreshBoardService: RefreshBoardService,
private navigationService: OnlineBoardNavigationService,
private requestBuilder: OnlineBoardRequestBuilderService,
) {}
ngOnInit(): void {
this.route.data.subscribe({
next: (data: IOnlineBoardRouteData) => {
if (data.urlParams?.route) {
this.params = data.urlParams.route;
this.dataSource.loadFlightsByRoute(this.params);
this.refreshBoardService.start(this.apiFormatter.formatDateOnly(this.params.date), this.params);
}
this.updateCalendar();
},
});
}
ngOnDestroy() {
this.refreshBoardService.stop();
}
async updateCalendar() {
const isDepartureValid = await this.validationService.validateCode(this.params.departure);
const isArrivalValid = await this.validationService.validateCode(this.params.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.params.departure: undefined, isArrivalValid ? this.params.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();
}
}
public handleSameUrlNavigation() {
this.dataSource.loadFlightsByRoute(this.params);
}
public handleRequestCancellation() {
this.dataSource.stopLoading();
return this.navigationService.toStartPage();
}
public handleDateChange(date: Date) {
return this.navigationService.toRoutePage({
...this.params,
date,
});
}
public handleTimeRangeChange(timeRange?: IUrlTimeRange) {
return this.navigationService.toRoutePage({
...this.params,
timeFrom: timeRange?.timeFrom,
timeTo: timeRange?.timeTo,
});
}
public navigateToDetails(flight: ISimpleFlight) {
const request = this.requestBuilder.buildRouteRequest(this.params);
return this.navigationService.toDetailsPage({
flightId: flight.flightId,
date: FlightDateUtils.getFlightDate(flight),
request,
});
}
}
@@ -0,0 +1,55 @@
import { Inject, Injectable } from '@angular/core';
import * as signalR from "@microsoft/signalr";
import { environment } from '@environment';
import { interval, Subscription } from 'rxjs';
import { AppSettings } from '@shared/models-legacy';
import { APP_SETTINGS } from '@shared/services';
import { FlightStatus } from '@typings/enums';
import { IOnlineBoardRoutePageUrlParams } from '@online-board/services/url/types';
import { OnlineBoardDataSourceService } from '@online-board/services/data-source.service';
import { IFlightId, IParsedFlightId } from '@typings/flight/flight-id';
import { FadeService } from '@online-board/services/fade.service';
@Injectable()
export class RefreshBoardService {
connection: any;
loadParams: IOnlineBoardRoutePageUrlParams;
constructor(
@Inject(APP_SETTINGS) private settings: AppSettings,
public dataSource: OnlineBoardDataSourceService,
private fadeService: FadeService,
) {}
init() {
this.connection = new signalR.HubConnectionBuilder()
.withUrl(environment.urlForTrackerHub)
.withAutomaticReconnect()
.build();
this.connection.on('RefreshDate', (date: string) => {
this.dataSource.loadFlightsByRoute(this.loadParams, true);
});
}
start(flightDate: string, params: IOnlineBoardRoutePageUrlParams) {
this.loadParams = params;
if (!this.connection) {
this.init();
}
// if connection is disconnected then stop just console.log about it and returns, then we start it
this.connection.stop().then(() => {
this.fadeService.start();
this.connection.start().then(() => {
this.connection.invoke("SubscribeDate", flightDate, params.departure, params.arrival);
});
});
}
stop() {
this.connection.stop();
this.fadeService.stop();
}
}
@@ -0,0 +1,86 @@
<div *localisationReady>
<page-layout [withScrollUp]="withScrollUp">
<ng-container title>
<ng-content select="[title]"></ng-content>
</ng-container>
<ng-container header-left>
<spin-lock *ngIf="loading"></spin-lock>
<flights-page-tabs
[viewType]="ViewType.Onlineboard"
></flights-page-tabs>
</ng-container>
<ng-container content-left>
<spin-lock *ngIf="loading"></spin-lock>
<online-board-filter></online-board-filter>
<search-history></search-history>
</ng-container>
<ng-container sticky-content>
<card [roundedTop]="true">
<spin-lock *ngIf="loading"></spin-lock>
<day-tabs
class="board-day-selector"
caption="SHARED.FLIGHT_DATE"
[selectedDate]="searchDate"
[disabledDates]="disabledDates"
[dateTo]="settings.boardMaxDate"
[dateFrom]="settings.boardMinDate"
[minDate]="settings.boardMinDate"
[maxDate]="settings.boardMaxDate"
(tabClick)="handleDateChanged($event)"
></day-tabs>
<time-selector
*ngIf="featureFlags.RESULTS_TIME_SELECTOR"
[fullView]="true"
[(ngModel)]="timeRange"
[label]="
requestType === FlightRequestType.Arrival
? ('SHARED.ARRIVAL_TIME' | translate)
: ('SHARED.DEPARTURE_TIME' | translate)
"
(slideComplete)="handleTimeChanged()"
[disabled]="requestType === FlightRequestType.Flight"
>
</time-selector>
</card>
</ng-container>
<ng-container>
<page-loader
data-testid="loader"
*ngIf="loading"
(searchCancel)="cancel()"
></page-loader>
<ng-container *ngIf="!loading">
<board-search-result
[currentFlight]="currentFlight"
[flights]="flights"
(toDetails)="handleToDetailsEvent($event)"
(changeCurrentFlight)="handleChangeCurrentFlightEvent($event)"
></board-search-result>
<ng-container *ngIf="!flights?.length">
<page-empty-list
*ngIf="
requestType !== FlightRequestType.Flight ||
!partners?.length
"
[scrollTo]="true"
></page-empty-list>
<partners-redirect-note
*ngIf="
requestType === FlightRequestType.Flight &&
partners?.length
"
[scrollTo]="true"
[partners]="partners"
></partners-redirect-note>
</ng-container>
<page-footer-notes></page-footer-notes>
</ng-container>
</ng-container>
</page-layout>
</div>
@@ -0,0 +1,11 @@
@use 'src/styles/variables' as vars;
@use 'src/styles/screen';
.board-day-selector {
@include screen.mobile {
display: flex;
flex-direction: column;
padding: vars.$space-xl;
}
}
@@ -0,0 +1,146 @@
import {
Component,
EventEmitter,
Inject,
Input,
OnChanges,
Output,
SimpleChanges,
} from '@angular/core';
import { partnersWithRedirect } from '@modules/components/partners-redirect-note/partners-redirect-note.component';
import {
FlightRequestType,
ViewType,
} from '@shared/enumerators/flight-request-type.enum';
import { AppSettings } from '@shared/models-legacy';
import { FlightModel, ListItem } from '@shared/models-legacy/flight.model';
import { APP_SETTINGS } from '@shared/services';
import { FeatureFlagsService } from '@shared/services/feature-flags.service';
import { IUrlTimeRange } from '@typings/common/url';
import { ISimpleFlight } from '@typings/flight/flight';
import { findClosestFlight } from './utils/find-closest-flight';
@Component({
selector: 'online-board-search',
templateUrl: './online-board-search.component.html',
styleUrls: ['./online-board-search.component.scss'],
})
export class OnlineBoardSearchComponent implements OnChanges {
@Input() flights: ListItem<FlightModel>[] = [];
@Input() partners: string[] = [];
@Input() searchDate: Date;
@Input() loading = false;
@Input() requestType: FlightRequestType;
@Input() timeFrom: string;
@Input() timeTo: string;
@Input() withScrollUp = true;
@Input() disabledDates: Date[];
@Output() requestCancel = new EventEmitter<boolean>();
@Output() searchDateChange = new EventEmitter<Date>();
@Output() timeRangeChange = new EventEmitter<IUrlTimeRange>();
@Output() toDetails = new EventEmitter<ISimpleFlight>();
timeRange: IUrlTimeRange;
currentFlight: ListItem<FlightModel>;
FlightRequestType = FlightRequestType;
ViewType = ViewType;
constructor(
public featureFlags: FeatureFlagsService,
@Inject(APP_SETTINGS) public settings: AppSettings,
) {}
ngOnChanges(changes: SimpleChanges) {
if ('flights' in changes) {
if ('loading' in changes) {
// Queue new macrotasks to let browser rerender after
// creating flights (long task - about 60ms).
// After removing populate.logic.ts setTimeout can be removed
setTimeout(() => {
this.setCurrentFlight(this.flights); // about 16ms - if not queued drops frames
});
} else {
//console.log("Got autorefresh flights");
//console.log(this.currentFlight);
setTimeout(() => {
if (this.currentFlight) {
this.flights.forEach((flight) => {
if (flight.id === this.currentFlight.id) {
flight.expanded = this.currentFlight.expanded;
this.currentFlight = flight;
}
});
}
});
}
}
if ('partners' in changes) {
setTimeout(() => {
this.partners = partnersWithRedirect(this.partners);
});
}
if ('timeFrom' in changes || 'timeTo' in changes) {
this.setTimeRange();
}
}
setCurrentFlight(flights?: ListItem<FlightModel>[]) {
if (!this.flights) {
return;
}
if (this.currentFlight) {
this.currentFlight.expanded = false;
}
this.currentFlight = findClosestFlight(
flights,
this.searchDate,
this.requestType === FlightRequestType.Arrival,
);
if (!this.currentFlight) {
return;
}
this.currentFlight.expanded = true;
}
setTimeRange() {
if (!this.timeFrom || !this.timeTo) {
this.timeRange = {};
return;
}
this.timeRange = {
timeFrom: this.timeFrom,
timeTo: this.timeTo,
};
}
handleDateChanged(date: Date) {
this.searchDateChange.emit(date);
}
handleTimeChanged() {
this.timeRangeChange.emit(this.timeRange);
}
handleToDetailsEvent(flight: ISimpleFlight) {
this.toDetails.emit(flight);
}
handleChangeCurrentFlightEvent(flight: ListItem<FlightModel>) {
this.currentFlight = flight;
//console.log("change current flight");
//console.log(this.currentFlight);
}
cancel() {
this.requestCancel.emit(true);
}
}
@@ -0,0 +1,565 @@
import { findClosestFlight } from '@online-board/pages/search/components/view/utils/find-closest-flight';
const mockedFlights = {
yesterday: [
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-16T21:48:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-16T21:48:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-17T04:05:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-17T04:05:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-17T06:12:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-17T06:12:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-17T06:57:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-17T06:57:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-17T09:16:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-17T09:16:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-17T13:29:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-17T13:29:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-17T16:35:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-17T16:35:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-17T18:05:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-17T18:05:00',
},
},
},
},
},
],
today: [
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-17T21:48:00',
},
scheduled: {
local: '2021-12-17T21:40:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-17T21:48:00',
},
scheduled: {
local: '2021-12-17T21:40:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
scheduled: {
local: '2021-12-18T04:05:00',
},
},
},
departure: {
times: {
scheduled: {
local: '2021-12-18T04:05:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-18T06:12:00',
},
scheduled: {
local: '2021-12-18T05:55:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-18T06:12:00',
},
scheduled: {
local: '2021-12-18T05:55:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-18T06:57:00',
},
scheduled: {
local: '2021-12-18T07:00:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-18T06:57:00',
},
scheduled: {
local: '2021-12-18T07:00:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-18T09:16:00',
},
scheduled: {
local: '2021-12-18T09:05:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-18T09:16:00',
},
scheduled: {
local: '2021-12-18T09:05:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
actual: {
local: '2021-12-18T13:29:00',
},
scheduled: {
local: '2021-12-18T13:10:00',
},
},
},
departure: {
times: {
actual: {
local: '2021-12-18T13:29:00',
},
scheduled: {
local: '2021-12-18T13:10:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
scheduled: {
local: '2021-12-18T16:35:00',
},
},
},
departure: {
times: {
scheduled: {
local: '2021-12-18T16:35:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
scheduled: {
local: '2021-12-18T18:05:00',
},
},
},
departure: {
times: {
scheduled: {
local: '2021-12-18T18:05:00',
},
},
},
},
},
],
tomorrow: [
{
routeType: 'Direct',
leg: {
arrival: {
times: {
scheduled: {
local: '2021-12-18T21:40:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
scheduled: {
local: '2021-12-19T04:05:00',
},
},
},
departure: {
times: {
scheduled: {
local: '2021-12-19T04:05:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
scheduled: {
local: '2021-12-19T05:55:00',
},
},
},
departure: {
times: {
scheduled: {
local: '2021-12-19T05:55:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
scheduled: {
local: '2021-12-19T07:00:00',
},
},
},
departure: {
times: {
scheduled: {
local: '2021-12-19T07:00:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
scheduled: {
local: '2021-12-19T09:05:00',
},
},
},
departure: {
times: {
scheduled: {
local: '2021-12-19T09:05:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
scheduled: {
local: '2021-12-19T13:10:00',
},
},
},
departure: {
times: {
scheduled: {
local: '2021-12-19T13:10:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
scheduled: {
local: '2021-12-19T16:35:00',
},
},
},
departure: {
times: {
scheduled: {
local: '2021-12-19T16:35:00',
},
},
},
},
},
{
routeType: 'Direct',
leg: {
arrival: {
times: {
scheduled: {
local: '2021-12-19T18:05:00',
},
},
},
departure: {
times: {
scheduled: {
local: '2021-12-19T18:05:00',
},
},
},
},
},
],
};
describe('findClosestFlight', () => {
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should select the most closest future flight for today', () => {
mockDate('2021-12-18T15:26:28');
const flights = mockedFlights.today as any;
const flight = findClosestFlight(flights, new Date('2021-12-18'), true);
expect(flight).toEqual(flights[6]);
});
it('should select the most closest future flight for today if isArrival is false', () => {
mockDate('2021-12-18T15:26:28');
const flights = mockedFlights.today as any;
const flight = findClosestFlight(
flights,
new Date('2021-12-18'),
false,
);
expect(flight).toEqual(flights[6]);
});
it('should select the most closest completed flight for today', () => {
mockDate('2021-12-18T13:46:28');
const flights = mockedFlights.today as any;
const flight = findClosestFlight(flights, new Date('2021-12-18'), true);
expect(flight).toEqual(flights[5]);
});
it('should select last flight', () => {
mockDate('2021-12-18T19:46:28');
const flights = mockedFlights.today as any;
const flight = findClosestFlight(flights, new Date('2021-12-18'), true);
expect(flight).toEqual(flights[7]);
});
it('should select the first for tomorrow', () => {
mockDate('2021-12-18T15:26:28');
const flights = mockedFlights.tomorrow as any;
const flight = findClosestFlight(flights, new Date('2021-12-17'), true);
expect(flight).toEqual(flights[0]);
});
it('should select the last for yesterday', () => {
mockDate('2021-12-18T15:26:28');
const flights = mockedFlights.yesterday as any;
const flight = findClosestFlight(flights, new Date('2021-12-19'), true);
expect(flight).toEqual(flights[7]);
});
it('should return undefined if no flights were provided', () => {
expect(findClosestFlight([], new Date('2021-12-19'), true)).toBe(
undefined,
);
expect(findClosestFlight(undefined, new Date('2021-12-19'), true)).toBe(
undefined,
);
});
});
function mockDate(date: string) {
const currentDate = new Date(date);
jasmine.clock().mockDate(currentDate);
}
@@ -0,0 +1,65 @@
import { Moment } from 'moment';
import * as moment from 'moment';
import { FlightModel, getFirstLeg, getLastLeg } from '@shared/models-legacy';
export function findClosestFlight(
flights: FlightModel[] = [],
flightDate: moment.MomentInput,
isArrival: boolean,
) {
if (!flights.length) {
return;
}
const searchForToday = moment().isSame(flightDate, 'day');
return searchForToday
? findClosestFlightForToday(flights, isArrival)
: findClosestFlightNotForToday(flights, isArrival);
}
function findClosestFlightForToday(flights: FlightModel[], isArrival: boolean) {
const now = moment();
let min = Number.MAX_VALUE;
let candidate: FlightModel = flights[0];
flights.forEach((flight) => {
const diff = getDiffFromNow(flight, now, isArrival);
// It is necessary to call Math.abs with min
// because min can be negative too
if (Math.abs(diff) < Math.abs(min)) {
min = diff;
candidate = flight;
}
});
return candidate;
}
function findClosestFlightNotForToday(
flights: FlightModel[],
isArrival: boolean,
) {
const firstFlight = flights[0];
const lastFlight = flights[flights.length - 1];
const firstFlightTime = getFlightTime(firstFlight, isArrival);
// For tomorrow returns first flight for yesterday - last
return moment().isBefore(firstFlightTime) ? firstFlight : lastFlight;
}
function getDiffFromNow(flight: FlightModel, now: Moment, isArrival: boolean) {
const time = getFlightTime(flight, isArrival);
return moment(time).diff(now);
}
function getFlightTime(flight: FlightModel, isArrival: boolean) {
const times = isArrival
? getLastLeg(flight).arrival.times
: getFirstLeg(flight).departure.times;
return (times.actual ?? times.latest ?? times.scheduled).local;
}
@@ -0,0 +1 @@
<meta-tags [title]="title" [description]="description"></meta-tags>
@@ -0,0 +1,74 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { OnlineBoardArrivalMetaTagsComponent } from '@online-board/pages/search/arrival/components/meta-tags/online-board-arrival-meta-tags.component';
import { OnlineBoardDepartureMetaTagsComponent } from '@online-board/pages/search/departure/components/meta-tags/online-board-departure-meta-tags.component';
import { OnlineBoardMetaTagsService } from '@online-board/services/meta-tags.service';
import { IOnlineBoardRouteData } from '@online-board/types';
import { BehaviorSubject } from 'rxjs';
describe('OnlineBoardDepartureMetaTagsComponent', () => {
let component: OnlineBoardDepartureMetaTagsComponent;
let fixture: ComponentFixture<OnlineBoardDepartureMetaTagsComponent>;
const getDepartureMetaTagsMock = jasmine.createSpy('getDepartureMetaTags');
const data: IOnlineBoardRouteData = {
urlParams: {},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [OnlineBoardArrivalMetaTagsComponent],
providers: [
{
provide: OnlineBoardMetaTagsService,
useValue: {
getDepartureMetaTags: getDepartureMetaTagsMock,
},
},
{
provide: ActivatedRoute,
useValue: {
data: new BehaviorSubject(data),
},
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(
OnlineBoardDepartureMetaTagsComponent,
);
component = fixture.componentInstance;
data.urlParams.route = undefined;
getDepartureMetaTagsMock.calls.reset();
});
it('should not set title and description because route param is not provided', () => {
component.ngOnInit();
expect(component.title).toBe('');
expect(component.description).toBe('');
});
it('should set title and description', async () => {
data.urlParams.route = {
departure: 'MOW',
date: new Date(2022, 4, 28),
};
getDepartureMetaTagsMock.withArgs(data.urlParams.route).and.returnValue(
Promise.resolve({
title: 'departure title',
description: 'departure description',
}),
);
component.ngOnInit();
await Promise.resolve();
expect(component.title).toBe('departure title');
expect(component.description).toBe('departure description');
});
});
@@ -0,0 +1,32 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { OnlineBoardMetaTagsService } from '@online-board/services/meta-tags.service';
import { IOnlineBoardRouteData } from '@online-board/types';
@Component({
selector: 'online-board-departure-meta-tags',
templateUrl: './online-board-departure-meta-tags.component.html',
})
export class OnlineBoardDepartureMetaTagsComponent implements OnInit {
title = '';
description = '';
constructor(
private route: ActivatedRoute,
private metaTagsService: OnlineBoardMetaTagsService,
) {}
ngOnInit() {
this.route.data.subscribe(async (data: IOnlineBoardRouteData) => {
if (data.urlParams.route) {
const metaTags =
await this.metaTagsService.getDepartureMetaTags(
data.urlParams.route,
);
this.title = metaTags.title;
this.description = metaTags.description;
}
});
}
}
@@ -0,0 +1 @@
<aero-title [title]="title"></aero-title>
@@ -0,0 +1,66 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { OnlineBoardDepartureTitleComponent } from '@online-board/pages/search/departure/components/title/online-board-departure-title.component';
import { OnlineBoardTitleService } from '@online-board/services/title.service';
import { IOnlineBoardRouteData } from '@online-board/types';
import { BehaviorSubject } from 'rxjs';
describe('OnlineBoardDepartureTitleComponent', () => {
let component: OnlineBoardDepartureTitleComponent;
let fixture: ComponentFixture<OnlineBoardDepartureTitleComponent>;
const getDepartureTitleMock = jasmine.createSpy('getDepartureTitle');
const data: IOnlineBoardRouteData = {
urlParams: {},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [OnlineBoardDepartureTitleComponent],
providers: [
{
provide: OnlineBoardTitleService,
useValue: {
getDepartureTitle: getDepartureTitleMock,
},
},
{
provide: ActivatedRoute,
useValue: {
data: new BehaviorSubject(data),
},
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(OnlineBoardDepartureTitleComponent);
component = fixture.componentInstance;
data.urlParams.route = undefined;
getDepartureTitleMock.calls.reset();
});
it('should not set title because route param is not provided', () => {
component.ngOnInit();
expect(component.title).toBe('');
});
it('should set title', async () => {
data.urlParams.route = {
departure: 'MOW',
date: new Date(2022, 4, 28),
};
getDepartureTitleMock
.withArgs(data.urlParams.route)
.and.returnValue(Promise.resolve('departure title'));
component.ngOnInit();
await Promise.resolve();
expect(component.title).toBe('departure title');
});
});
@@ -0,0 +1,29 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { OnlineBoardTitleService } from '@online-board/services/title.service';
import { IOnlineBoardRouteData } from '@online-board/types';
@Component({
selector: 'online-board-departure-title',
templateUrl: './online-board-departure-title.component.html',
})
export class OnlineBoardDepartureTitleComponent implements OnInit {
title = '';
constructor(
private route: ActivatedRoute,
private titleService: OnlineBoardTitleService,
) {}
ngOnInit() {
this.route.data.subscribe({
next: async (data: IOnlineBoardRouteData) => {
if (data.urlParams.route) {
this.title = await this.titleService.getDepartureTitle(
data.urlParams.route,
);
}
},
});
}
}
@@ -0,0 +1,4 @@
<online-board-departure-meta-tags></online-board-departure-meta-tags>
<online-board-search-page-base [requestType]="FlightRequestType.Departure">
<online-board-departure-title title></online-board-departure-title>
</online-board-search-page-base>

Some files were not shown because too many files have changed in this diff Show More