Перейдем к созданию формы поиска билетов и жилья.
Посмотрим пример на сайте - travel.alfabank.ru
Там представлены следующие поля:
При поиске авиабилетов, будут все поля, а в случае подбора отеля только их часть.
Невозможно забронировать жилье в разных местах (вот такая негибкая система, хотя в airbnb это предусмотрено).
Создадим раздел src/search в котором будем хранить все, что связано с поиском.
Добавим пару интерфейсов:
export interface SearchDestination { readonly [key: string]: unknown; readonly id: string; readonly type: string; readonly code: string; readonly name: string; readonly country_name: string; readonly city_name: string; readonly value: string; // ??? } export interface SearchFieldOptions { readonly [key: string]: unknown; readonly id: string; readonly label: string; readonly name?: string; readonly placeholder?: string; } export type SearchFormOptions= { readonly [P in keyof T]: SearchFieldOptions; }; export function getSearchQueryParams( form: Readonly >>, ): Record { const params: Record = {}; for (const [key, value] of Object.entries(form)) { if (!!value && typeof value === 'object') { params[key] = 'value' in value ? value['value'] : undefined; } else { params[key] = value; } } return params; }
Начнем реализацию формы с поиска авиабилетов, так как это включает весь набор полей.
Создадим раздел avia:
mkdir src/app/search/avia mkdir src/app/search/avia/common mkdir src/app/search/avia/common/lib echo >src/app/search/avia/common/index.ts
Добавим интерфейсы:
import { castQueryParams } from '@baf/core'; export interface SearchDeclination { readonly vi: string; readonly tv: string; readonly su: string; readonly ro: string; readonly pr: string; readonly da: string; } export interface SearchCityOrAirportDTO { readonly id: string; readonly type: string; readonly code: string; readonly name: string; readonly country_code: string; readonly country_name: string; readonly city_name?: string; readonly state_code: string | null; readonly coordinates: { readonly lon: number; readonly lat: number; }; readonly index_strings: unknown[]; readonly weight: number; readonly cases: SearchDeclination | null; readonly country_cases: SearchDeclination | null; readonly main_airport_name: string | null; } export interface SearchFlightOptions { readonly [key: string]: unknown; readonly currency: string; readonly origin: string; readonly destination: string; readonly departure_at: string; readonly return_at?: string; readonly one_way?: string; readonly direct?: boolean; readonly unique?: boolean; readonly limit?: number; readonly page?: number; readonly soring?: string; readonly token: string; } export interface SearchFlight { readonly origin: string; readonly destination: string; readonly origin_airport: string; readonly destination_airport: string; readonly price: number; readonly airline: string; readonly flight_number: string; readonly departure_at: string; readonly return_at: string; readonly transfers: number; readonly return_transfers: number; readonly duration: number; readonly duration_to: number; readonly duration_back: number; readonly link: string; } export interface SearchFlightResponse { readonly success: boolean; readonly data: SearchFlight[]; readonly currency: string; } export interface SearchAviaLine { readonly origin: string; readonly originName: string; readonly destination: string; readonly destinationName: string; readonly duration: number; readonly departureAt: string; readonly arriveAt: string; readonly transfers: number; } export function getSearchFlightOptions(queryParams: Record, token: string, currency: string): SearchFlightOptions { const { from, to, direct, startDate, endDate } = castQueryParams(queryParams); if ( typeof from !== 'string' || typeof to !== 'string' || (typeof direct !== 'boolean' && typeof direct !== 'undefined') || typeof startDate !== 'string' || (typeof endDate !== 'string' && typeof endDate !== 'undefined') ) { throw new Error('Invalid search flight options'); } return { origin: from, destination: to, direct, currency: currency.toLowerCase(), departure_at: startDate, return_at: endDate, token, sorting: 'price', }; }
Теперь зададим саму форму:
import { FormControl, FormGroup, Validators } from '@angular/forms'; import type { FormFor } from '@baf/core'; import type { SearchDestination } from '@baf/search/common'; export interface SearchAviaForm { readonly from: string | SearchDestination; readonly to: string | SearchDestination; readonly startDate: string; readonly endDate: string; readonly passengers: number; } export type SearchAviaFormGroup = FormGroup>; export const initialSearchAviaFormGroup: SearchAviaFormGroup = new FormGroup({ from: new FormControl ('', { nonNullable: true, validators: [Validators.required], }), to: new FormControl ('', { nonNullable: true, validators: [Validators.required], }), startDate: new FormControl ('', { nonNullable: true, validators: [Validators.required], }), endDate: new FormControl ('', { nonNullable: true, validators: [], }), passengers: new FormControl (1, { nonNullable: true, validators: [Validators.required, Validators.min(1), Validators.max(20)], }), });
И также, определим фильтры:
import { FormControl, FormGroup } from '@angular/forms'; import type { FormFor } from '@baf/core'; export interface SearchAviaFilters { readonly baggage: boolean; readonly direct: boolean; } export type SearchAviaFiltersGroup = FormGroup>; export const initialSearchAviaFiltersGroup: SearchAviaFiltersGroup = new FormGroup({ baggage: new FormControl(false, { nonNullable: true, validators: [] }), direct: new FormControl(false, { nonNullable: true, validators: [] }), });
SearchAviaFilters - доступные значения;
SearchAviaFiltersGroup - angular reactive form;
initialSearchAviaFiltersGroup - начальное состояние.
Добавим раздел search/avia/services, в котором будет сервисы для обращения к внешнему API:
mkdir src/app/search/avia/services mkdir src/app/search/avia/services/lib echo >src/app/search/avia/services/index.ts
Реализация:
import { HttpClient } from '@angular/common/http'; import { DEFAULT_CURRENCY_CODE, inject, Injectable, TransferState } from '@angular/core'; import type { Observable } from 'rxjs'; import { map } from 'rxjs'; import type { Environment } from '@baf/core'; import { castParams, ENV_DEFAULT, ENV_KEY } from '@baf/core'; import type { SearchFlight, SearchFlightResponse } from '@baf/search/avia/common'; import { getSearchFlightOptions } from '@baf/search/avia/common'; @Injectable() export class SearchAviaService { private readonly httpClient = inject(HttpClient); private readonly environment = inject(TransferState).get(ENV_KEY, ENV_DEFAULT); private readonly currency = inject(DEFAULT_CURRENCY_CODE); findFlights(queryParams: Record ): Observable { const params = castParams(getSearchFlightOptions(queryParams, this.environment.aviasalesToken, this.currency)); return this.httpClient.get ('/api/aviasales/v3/prices_for_dates', { params }).pipe(map(({ data }) => data)); } }
SearchAviaService содержит всего один метод - findFlights:
Для локальной разработки нужно настроить прокси.
Установим dotenv:
yarn add -D dotenv
Затем в main.server.ts подключением env:
import { bootstrapApplication } from '@angular/platform-browser'; import dotenv from 'dotenv'; import { AppComponent } from './app/app.component'; import { config } from './app/app.config.server'; dotenv.config(); const bootstrap = () => bootstrapApplication(AppComponent, config); export default bootstrap;
Создадим proxy.config.json:
{ "/api/autocomplete": { "target": "https://autocomplete.travelpayouts.com", "secure": false, "pathRewrite": { "^/api/autocomplete": "" }, "changeOrigin": true }, "/api/aviasales": { "target": "https://api.travelpayouts.com", "secure": false, "pathRewrite": { "^/api": "" }, "changeOrigin": true }, "/api/hotels": { "target": "https://engine.hotellook.com/api/v2", "secure": false, "pathRewrite": { "^/api/hotels": "" }, "changeOrigin": true } }
В angular.json пропишем proxyConfig:
{ ..., "serve": { "builder": "@angular-devkit/build-angular:dev-server", "options": { "proxyConfig": "src/proxy.conf.json" } } }
В app.config.server.ts подключим envs:
export const config = mergeApplicationConfig(envConfig, appConfig, serverConfig);
В корне проекта добавим .env со следующими токенами:
AVIASALES_TOKEN=YourTokenForTravelPayouts HOTELLOOK_TOKEN=YourTokenForTravelPayouts
Запустим и протестируем.
Можно вывести в консоли переменные.
Для production я добавил прокси на базе сервера ноды
server.ts:
import { APP_BASE_HREF } from '@angular/common'; import { CommonEngine } from '@angular/ssr'; import express from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import bootstrap from './src/main.server'; // The Express app is exported so that it can be used by serverless Functions. export function app(): express.Express { const server = express(); const serverDistFolder = dirname(fileURLToPath(import.meta.url)); const locale = serverDistFolder.split('/').at(-1) ?? ''; const browserDistFolder = resolve(serverDistFolder, '../../browser', locale); const indexHtml = join(serverDistFolder, 'index.server.html'); const commonEngine = new CommonEngine(); // Note: Don't use in production! For tutorial only... server.use( '/api/autocomplete', createProxyMiddleware({ target: 'https://autocomplete.travelpayouts.com', changeOrigin: true, secure: false, pathRewrite: { '^/api/autocomplete': '', }, }), ); server.use( '/api/aviasales', createProxyMiddleware({ target: 'https://api.travelpayouts.com/aviasales', secure: false, pathRewrite: { '^/api/aviasales': '', }, changeOrigin: true, }), ); server.use( '/api/hotels', createProxyMiddleware({ target: 'https://engine.hotellook.com/api/v2', secure: false, pathRewrite: { '^/api/hotels': '', }, changeOrigin: true, }), ); server.set('view engine', 'html'); server.set('views', browserDistFolder); // Example Express Rest API endpoints // server.get('/api/**', (req, res) => { }); // Serve static files from /browser server.get( '**', express.static(browserDistFolder, { maxAge: '1y', index: 'index.html', }), ); // All regular routes use the Angular engine server.get('**', (req, res, next) => { const { protocol, originalUrl, baseUrl, headers } = req; commonEngine .render({ bootstrap, documentFilePath: indexHtml, url: `${protocol}://${headers.host}${originalUrl}`, publicPath: browserDistFolder, providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }], }) .then((html) => res.send(html)) .catch((err) => next(err)); }); return server; } function run(): void { const port = process.env['PORT'] || 4000; // Start up the Node server const server = app(); server.listen(port, () => { console.log(`Node Express server listening on http://localhost:${port}`); }); } run();
Как видно из примера, я использую http-proxy-middleware.
Так как все формы похожи, создадим общие компоненты для их реализации.
Генерируем поля:
mkdir src/app/search/ui mkdir src/app/search/ui/fields mkdir src/app/search/ui/fields/lib echo >src/app/search/ui/fields/index.ts
Нам потребуются следующие контролы:
Также 2 специальных компонента:
Запустим команду:
yarn ng g c search-date
В разметку добавим datepicker:
Немного стилей:
@use 'src/stylesheets/device' as device; :host { width: 100%; &.is-hide { display: none; } &.is-start-date.is-valid { border-right: 1px solid var(--md-sys-color-background); } @include device.media-web() { &.is-hide { display: flex; flex-grow: 1; } &.is-start-date, &.is-start-date.is-valid { border-left: 1px solid var(--md-sys-color-background); border-right: 1px solid var(--md-sys-color-background); } &.is-end-date { border-right: 1px solid var(--md-sys-color-background); } } }
Реализация самого компонента достаточно тривиальна.
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import type { FormControl } from '@angular/forms'; import { camelCaseToHumanize, ExtraClassService } from '@baf/core'; import type { SearchFieldOptions } from '@baf/search/common'; import type { DatepickerOptions } from '@baf/ui/datepicker'; import { DatepickerComponent } from '@baf/ui/datepicker'; export interface SearchDateOptions extends SearchFieldOptions { readonly startDate?: FormControl; } @Component({ selector: 'baf-search-date', standalone: true, imports: [DatepickerComponent], templateUrl: './search-date.component.html', styleUrl: './search-date.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, providers: [ExtraClassService], }) export class SearchDateComponent { private readonly extraClassService = inject(ExtraClassService); readonly control = input.required , FormControl >({ transform: (value) => { this.extraClassService.register('valid', value.valueChanges, () => { this.extraClassService.patch('is-valid', value.valid); }); return value; }, }); readonly options = input.required ({ transform: (value) => { this.extraClassService.update('options', value.id ? `is-${camelCaseToHumanize(value.id)}` : ''); if (value.startDate) { this.extraClassService.register('invalid', value.startDate.valueChanges, () => { this.extraClassService.patch('is-hide', !!value.startDate?.invalid); }); } return { ...value, mask: '00.00.0000', maskTo: (val: string) => { const [year, month, day] = val.split('-'); return `${day}.${month}.${year}`; }, maskForm: (val: string) => { const [day, month, year] = val.split('.'); return `${year}-${month}-${day}`; }, }; }, }); }
Отмечу, что ExtraClassService используется только для того, чтобы не прибегать к HostBinding. Я все экспериментирую с автоматизацией стилизации. Но видимо, это не самое удачное решение.
Создадим место назначения:
yarn ng g c search-destination
Шаблон:
Немного стилей:
@use 'src/stylesheets/device' as device; :host { width: 100%; &.is-from { border-bottom: 1px solid var(--md-sys-color-background); } @include device.media-web() { &.is-from { border-bottom: none; } } }
Реализация компонента сложнее даты:
import type { OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, DestroyRef, inject, input } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import type { FormControl } from '@angular/forms'; import { BehaviorSubject, debounceTime, EMPTY, of, switchMap, tap } from 'rxjs'; import { ExtraClassService, toClass } from '@baf/core'; import type { SearchDestination, SearchFieldOptions } from '@baf/search/common'; import type { AutocompleteOptions } from '@baf/ui/autocomplete'; import { AutocompleteComponent } from '@baf/ui/autocomplete'; import { InputComponent } from '@baf/ui/input'; import { SearchDestinationService } from './search-destination.service'; export interface SearchDestinationOptions extends SearchFieldOptions { readonly types?: string[]; readonly key?: string; } @Component({ selector: 'baf-search-destination', standalone: true, imports: [InputComponent, AutocompleteComponent], templateUrl: './search-destination.component.html', styleUrl: './search-destination.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, providers: [ExtraClassService, SearchDestinationService], }) export class SearchDestinationComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly searchDestinationService = inject(SearchDestinationService); private readonly extraClassService = inject(ExtraClassService); readonly control = input.required>(); readonly options = input.required ({ transform: (options) => { this.extraClassService.update('options', toClass(options.id)); return { ...options, key: options.key ?? 'code', displayFn: (item: SearchDestination) => { return `${item.name}, ${item.code}
${item.country_name}, ${item.city_name ?? item.name}`; }, inputDisplayFn: (item: SearchDestination | string) => { if (!item) { return ''; } if (typeof item === 'string') { return item; } return `${item.name}, ${item.code}`; }, }; }, }); readonly data$ = new BehaviorSubject([]); ngOnInit(): void { this.control() .valueChanges.pipe( debounceTime(300), switchMap((query) => { if (!query) { return of([]); } if (typeof query !== 'string') { return EMPTY; } return this.searchDestinationService.findDestination(query, this.options().key, this.options().types); }), tap((response) => this.data$.next(response.slice(0, 6))), takeUntilDestroyed(this.destroyRef), ) .subscribe(); } }
Также имеем два инпута: control и options. Однако, есть реактивное свойство - data$, который представляет собой массив мест назначения, полученный из API.
Добавим SearchDestinationService:
import { HttpClient } from '@angular/common/http'; import { inject, Injectable, LOCALE_ID } from '@angular/core'; import type { Observable } from 'rxjs'; import { map } from 'rxjs'; import type { SearchDestination } from '@baf/search/common'; @Injectable() export class SearchDestinationService { private readonly httpClient = inject(HttpClient); private readonly localeId = inject(LOCALE_ID); findDestination(term: string, key: string, types?: string[]): Observable{ const withTypes = types?.length ? `&${types.map((type) => `types[]=${type}`).join('&')}` : ''; return this.httpClient.get (`/api/autocomplete/places2?locale=${this.localeId}${withTypes}&term=${term}`).pipe( map((result) => result.map((item) => ({ ...item, value: item[key as 'code' | 'name'], })), ), ); } }
Сервис имеет всего один метод - findDestination, который возвращает список городов или аэропортов.
Если посмотреть реализацию, то можно увидеть, что при вводе названия вызывается findDestination:
ngOnInit(): void { this.control() .valueChanges.pipe( debounceTime(300), switchMap((query) => { if (!query) { return of([]); } if (typeof query !== 'string') { return EMPTY; } return this.searchDestinationService.findDestination(query, this.options().key, this.options().types); }), tap((response) => this.data$.next(response.slice(0, 6))), takeUntilDestroyed(this.destroyRef), ) .subscribe(); }
Важно, для работы API сначала нужно настроить прокси, так как используемое мной API будет резать запросы по CORS. Как это обойти расскажу ниже.
Добавим смену места.
Для этого создадим новый компонент:
yarn ng g c search-reverse
В шаблоне выведем кнопку с иконкой:
Немного стилей:
@use 'src/stylesheets/device' as device; :host { position: absolute; top: 0; right: 0; z-index: 100; @include device.media-web() { position: relative; top: initial; right: initial; background-color: var(--md-sys-color-surface-variant); color: var(--md-sys-color-on-surface-variant); display: flex; border-left: 1px solid var(--md-sys-color-background); border-right: 1px solid var(--md-sys-color-background); button { display: flex; align-items: center; height: 3rem; } } } button { line-height: 1; }
Реализация очень тривиальна:
import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import type { FormGroup } from '@angular/forms'; import { IconButtonComponent } from '@baf/ui/buttons'; import { SyncAltComponent } from '@baf/ui/icons'; @Component({ selector: 'baf-search-reverse', standalone: true, imports: [SyncAltComponent, IconButtonComponent], templateUrl: './search-reverse.component.html', styleUrl: './search-reverse.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchReverseComponent { readonly form = input.required(); onReverse(): void { const { from, to } = this.form().getRawValue(); if (from && to) { this.form().patchValue({ from: to, to: from }, { emitEvent: false }); } } }
Просто при клике меняем места назначения.
Контрол для количества пассажиров:
yarn ng g c search-passengers
Макет:
Логика:
import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import type { FormControl } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms'; import type { SearchFieldOptions } from '@baf/search/common'; import { InputComponent, InputControlComponent } from '@baf/ui/input'; import { LabelComponent } from '@baf/ui/label'; export type SearchPassengersOptions = SearchFieldOptions; @Component({ selector: 'baf-search-passengers', standalone: true, imports: [ReactiveFormsModule, InputComponent, InputControlComponent, LabelComponent], templateUrl: './search-passengers.component.html', styleUrl: './search-passengers.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchPassengersComponent { readonly control = input.required>(); readonly options = input.required (); }
yarn ng g c search-group
Суть всего компонента в получении mode и задания соответствующего класса:
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import { ExtraClassService, toClass } from '@baf/core'; export type SearchGroupType = 'destination' | 'date' | 'line' | 'submit' | 'single' | undefined; @Component({ selector: 'baf-search-group', standalone: true, imports: [], template: '', styleUrl: './search-group.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, providers: [ExtraClassService], }) export class SearchGroupComponent { private readonly extraClassService = inject(ExtraClassService); readonly mode = input (undefined, { transform: (value) => { this.extraClassService.update('mode', toClass(value)); return value; }, }); }
Сами стили:
@use 'src/stylesheets/device' as device; :host { display: flex; flex-direction: column; position: relative; flex-grow: 1; &.is-date { flex-direction: row; } &.is-single { flex-grow: 100; } &.is-line { flex-direction: row; gap: 1rem; } @include device.media-web() { flex-direction: row; &.is-line { gap: 0; } &.is-submit { gap: 0; margin-left: 1rem; } } }
После того как были созданы все поля, можно реализовать форму.
mkdir src/app/search/ui/form mkdir src/app/search/ui/form/lib echo >src/app/search/ui/form/index.ts
Запустим команду:
yarn ng g c search-group
В шаблон вставим содержимое:
Немного стилей:
@use 'src/stylesheets/device' as device; form { display: flex; flex-direction: column; gap: 1rem; @include device.media-web() { flex-direction: row; gap: 0; } }
Сама форма:
import type { OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, output } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import type { FormGroup } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { tap } from 'rxjs'; import type { PathValues } from '@baf/core'; import { castQueryParams, getRoute } from '@baf/core'; import { getSearchQueryParams } from '@baf/search/common'; import { SearchGroupComponent } from '@baf/search/ui/fields'; import { ButtonComponent } from '@baf/ui/buttons'; @Component({ selector: 'baf-search-form', standalone: true, imports: [SearchGroupComponent, ButtonComponent, ReactiveFormsModule], templateUrl: './search-form.component.html', styleUrl: './search-form.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchFormComponent implements OnInit { private readonly router = inject(Router); private readonly activatedRoute = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); readonly form = input.required(); readonly redirectTo = input.required (); readonly submitted = output(); ngOnInit(): void { this.activatedRoute.queryParams .pipe( tap((queryParams) => { const formData = castQueryParams(queryParams, Object.keys(this.form().controls)); if (Object.keys(formData).length) { this.form().patchValue(formData); } }), takeUntilDestroyed(this.destroyRef), ) .subscribe(); } onSubmit(): void { this.form().markAllAsTouched(); if (this.form().invalid) { return; } this.submitted.emit(); // Note: Auto redirect void this.router.navigate(getRoute(this.redirectTo()), { queryParams: getSearchQueryParams({ ...this.activatedRoute.snapshot.queryParams, ...this.form().getRawValue() }), }); } }
Форма добавляет общий метод отправки и после инициализации компонента заполняется данными.
this.activatedRoute.queryParams .pipe( tap((queryParams) => { const formData = castQueryParams(queryParams, Object.keys(this.form().controls)); if (Object.keys(formData).length) { this.form().patchValue(formData); } }), takeUntilDestroyed(this.destroyRef), ) .subscribe();
После клика произойдет перенаправление на указанный путь:
this.form().markAllAsTouched(); if (this.form().invalid) { return; } this.submitted.emit(); // Note: Auto redirect void this.router.navigate(getRoute(this.redirectTo()), { queryParams: getSearchQueryParams({ ...this.activatedRoute.snapshot.queryParams, ...this.form().getRawValue() }), });
Последним шагом добавим форму фильтров:
mkdir src/app/search/ui/filters mkdir src/app/search/ui/filters/lib echo >src/app/search/ui/filters/index.ts
Запустим команду:
yarn ng g c search-filters
Шаблон:
Немного стилей:
@use 'src/stylesheets/device' as device; form { display: flex; flex-direction: column; gap: 1rem; @include device.media-web() { flex-direction: row; gap: 0; } }
Логика схожа с работой ранее созданной формы:
import type { OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, output } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import type { FormGroup } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { tap } from 'rxjs'; import type { PathValues } from '@baf/core'; import { castQueryParams, getRoute } from '@baf/core'; import { getSearchQueryParams } from '@baf/search/common'; import { SearchGroupComponent } from '@baf/search/ui/fields'; import { ButtonComponent } from '@baf/ui/buttons'; @Component({ selector: 'baf-search-form', standalone: true, imports: [SearchGroupComponent, ButtonComponent, ReactiveFormsModule], templateUrl: './search-form.component.html', styleUrl: './search-form.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchFormComponent implements OnInit { private readonly router = inject(Router); private readonly activatedRoute = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); readonly form = input.required(); readonly redirectTo = input.required (); readonly submitted = output(); ngOnInit(): void { this.activatedRoute.queryParams .pipe( tap((queryParams) => { const formData = castQueryParams(queryParams, Object.keys(this.form().controls)); if (Object.keys(formData).length) { this.form().patchValue(formData); } }), takeUntilDestroyed(this.destroyRef), ) .subscribe(); } onSubmit(): void { this.form().markAllAsTouched(); if (this.form().invalid) { return; } this.submitted.emit(); // Note: Auto redirect void this.router.navigate(getRoute(this.redirectTo()), { queryParams: getSearchQueryParams({ ...this.activatedRoute.snapshot.queryParams, ...this.form().getRawValue() }), }); } }
При изменении любого значения идет редирект:
onApply(): void { void this.router.navigate([], { queryParams: { ...this.activatedRoute.snapshot.queryParams, refresh: new Date().toISOString(), }, }); }
refresh - это костыль. Используется как изменение в пути и запуска релоада страницы.
Сброс обнуляет все выбранные фильтры:
onReset(): void { this.form().reset(); }
Перейдем к созданию формы поиска авиабилетов.
mkdir src/app/search/avia/ui mkdir src/app/search/avia/ui/form mkdir src/app/search/avia/ui/form/lib echo >src/app/search/avia/ui/form/index.ts
Добавим компонент:
yarn ng g c search-avia-form
Опишем форму SearchAviaFormComponent:
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { PATHS } from '@baf/core'; import type { SearchAviaForm } from '@baf/search/avia/common'; import { initialSearchAviaFormGroup } from '@baf/search/avia/common'; import type { SearchFormOptions } from '@baf/search/common'; import { SearchDateComponent, SearchDestinationComponent, SearchGroupComponent, SearchPassengersComponent, SearchReverseComponent, } from '@baf/search/ui/fields'; import { SearchFormComponent } from '@baf/search/ui/form'; import { ButtonComponent } from '@baf/ui/buttons'; @Component({ selector: 'baf-search-avia-form', standalone: true, imports: [ SearchFormComponent, SearchGroupComponent, SearchDestinationComponent, SearchReverseComponent, SearchDateComponent, SearchPassengersComponent, ButtonComponent, ], templateUrl: './search-avia-form.component.html', styleUrl: './search-avia-form.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchAviaFormComponent { readonly form = initialSearchAviaFormGroup; readonly redirectTo = PATHS.searchAvia; readonly name = 'city_name'; readonly options: SearchFormOptions= { from: { label: $localize`:Search Field:Where from`, id: 'from', types: ['city', 'airport'] }, to: { label: $localize`:Search Field:Where to`, id: 'to', types: ['city', 'airport'] }, startDate: { label: $localize`:Search Field:When`, id: 'startDate' }, endDate: { label: $localize`:Search Field:When back`, id: 'endDate', startDate: this.form.controls.startDate }, passengers: { label: $localize`:Search Field:Passengers`, id: 'passengers' }, }; }
Вывдем все:
Как можно заметить, вся бизнес логика скрыта в дочерних компонентах.
SearchAviaFormComponent представляет собой конфиг формы.
По аналогии создадим фильтры авиабилетов.
mkdir src/app/search/avia/ui/filters mkdir src/app/search/avia/ui/filters/lib echo >src/app/search/avia/ui/filters/index.ts
Запустим команду:
yarn ng g c search-filters-avia
Зададим требуемые настройки:
import { ChangeDetectionStrategy, Component } from '@angular/core'; import type { SearchAviaFilters } from '@baf/search/avia/common'; import { initialSearchAviaFiltersGroup } from '@baf/search/avia/common'; import type { SearchFormOptions } from '@baf/search/common'; import { SearchFiltersComponent } from '@baf/search/ui/filters'; import { FilterBaggageComponent } from './filter-baggage/filter-baggage.component'; import { FilterDirectComponent } from './filter-direct/filter-direct.component'; @Component({ selector: 'baf-search-filters-avia', standalone: true, imports: [SearchFiltersComponent, FilterBaggageComponent, FilterDirectComponent], templateUrl: './search-filters-avia.component.html', styleUrl: './search-filters-avia.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchFiltersAviaComponent { readonly form = initialSearchAviaFiltersGroup; readonly options: SearchFormOptions= { baggage: { label: $localize`:Search Filter:Baggage`, id: 'baggage', name: 'baggage' }, direct: { label: $localize`:Search Filter:Direct`, id: 'direct', name: 'direct' }, }; }
И шаблон:
Из примера видно, что выводятся просто фильтры списком. Как и в случае с основной формой, вся логика вынесена в дочерние компоненты.
Пример фильтра:
{{ options().label }}
import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import type { FormControl } from '@angular/forms'; import { ExtractChangesDirective } from '@baf/core'; import type { SearchFieldOptions } from '@baf/search/common'; import { CheckboxComponent } from '@baf/ui/checkbox'; export type FilterDirectOptions = SearchFieldOptions; @Component({ selector: 'baf-filter-direct', standalone: true, imports: [CheckboxComponent], templateUrl: './filter-direct.component.html', styleUrl: './filter-direct.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [ { directive: ExtractChangesDirective, inputs: ['control'], }, ], }) export class FilterDirectComponent { readonly control = input.required>(); readonly options = input.required (); }
Теперь можно вывести форму на главной странице:
export const homeRoutes: Routes = [ { path: PATHS.homeAvia, title: $localize`:Home Title:Buy & Fly - Flights with 10% cashback`, loadComponent: () => import('@baf/home/page').then((m) => m.HomePageComponent), children: [ { path: '', loadComponent: () => import('@baf/search/avia/ui/form').then((m) => m.SearchAviaFormComponent), outlet: 'form', }, ], }, // ... ]
Подключим http в app.config.ts:
provideHttpClient(withFetch(), withInterceptors(httpInterceptors)), provideClientHydration(), provideCurrency('RUB'),
Запустим проект:
Создадим форму для бронирования отелей:
mkdir src/app/search/hotels mkdir src/app/search/hotels/common mkdir src/app/search/hotels/common/lib echo >src/app/search/hotels/common/index.ts
Опишем интерфейсы.
search-hotel.interface.ts:
export interface SearchLocation { readonly cityName: string; readonly fullName: string; readonly countryCode: string; readonly countryName: string; readonly iata: string[]; readonly id: string; readonly hotelsCount: string; readonly location: { readonly lat: string; readonly lon: string; }; readonly _score: number; } export interface SearchHotelInfo { readonly label: string; readonly locationName: string; readonly locationId: string; readonly id: string; readonly fullName: string; readonly location: { readonly lat: string; readonly lon: string; }; } export interface SearchHotelsResponse { readonly results: { readonly locations: SearchLocation[]; readonly hotels: SearchHotelInfo[]; }; readonly status: string; } export interface SearchHotelDto { readonly locationId: number; readonly hotelId: number; readonly priceFrom: number; readonly priceAvg: number; readonly pricePercentile: Record; readonly stars: number; readonly hotelName: string; readonly location: { readonly name: string; readonly country: string; readonly state: null | string; readonly geo: { readonly lat: number; readonly lon: number; }; }; } export interface SearchHotelDetails { readonly id: number; readonly cityId: number; readonly stars: number; readonly pricefrom: number; readonly rating: number; readonly popularity: number; readonly propertyType: number; readonly checkIn: string; readonly checkOut: string; readonly distance: number; readonly photoCount: number; readonly photos: { readonly url: string; readonly width: number; readonly height: number; }[]; readonly photosByRoomType: Record ; readonly yearOpened: number; readonly yearRenovated: null | number; readonly cntRooms: number; readonly cntSuites: null | number; readonly cntFloors: number; readonly facilities: number[]; readonly shortFacilities: string[]; readonly location: { readonly lon: number; readonly lat: number; }; readonly name: Record ; readonly address: Record ; readonly link: string; readonly poi_distance: unknown; } export interface SearchHotelsDetailsResponse { readonly pois: unknown[]; readonly hotels: SearchHotelDetails[]; readonly status: string; } export interface SearchHotel extends SearchHotelDto { readonly photos: { readonly url: string; readonly width: number; readonly height: number; }[]; }
search-hotel.filters.ts:
import { FormControl, FormGroup } from '@angular/forms'; import type { FormFor } from '@baf/core'; export interface SearchHotelFilters { readonly breakfast: boolean; readonly freeCancellation: boolean; readonly fiveStars: boolean; } export type SearchHotelFiltersGroup = FormGroup>; export const initialSearchHotelFiltersGroup: SearchHotelFiltersGroup = new FormGroup({ breakfast: new FormControl(false, { nonNullable: true, validators: [] }), fiveStars: new FormControl(false, { nonNullable: true, validators: [] }), freeCancellation: new FormControl(false, { nonNullable: true, validators: [] }), });
search-hotel.form.ts:
import { FormControl, FormGroup, Validators } from '@angular/forms'; import type { FormFor } from '@baf/core'; import type { SearchDestination } from '@baf/search/common'; export interface SearchHotelForm { readonly city: string | SearchDestination; readonly startDate: string; readonly endDate: string; readonly passengers: number | undefined; } export type SearchHotelFormGroup = FormGroup>; export const initialSearchHotelFormGroup: SearchHotelFormGroup = new FormGroup({ city: new FormControl ('', { nonNullable: true, validators: [Validators.required], }), startDate: new FormControl ('', { nonNullable: true, validators: [Validators.required], }), endDate: new FormControl ('', { nonNullable: true, validators: [], }), passengers: new FormControl (undefined, { nonNullable: true, validators: [Validators.required, Validators.min(1), Validators.max(20)], }), });
search-hotel.options.ts:
import { castQueryParams } from '@baf/core'; export interface SearchHotelsInfoOptions { readonly [key: string]: unknown; readonly query: string; readonly lang: string; readonly limit: number; readonly lookFor: string; } export function getSearchHotelsInfoOptions(queryParams: Record, lang: string): SearchHotelsInfoOptions { const { city } = castQueryParams(queryParams); if (typeof city !== 'string') { throw new Error('Invalid search flight options'); } const limit = !isNaN(Number(queryParams['limit'])) ? Number(queryParams['limit']) : 20; return { query: city, lang: lang.toLowerCase(), lookFor: 'hotel', limit, }; } export interface SearchHotelsOptions { readonly [key: string]: unknown; readonly location: string; readonly limit: number; readonly currency: string; readonly token: string; } export function getSearchHotelsOptions(queryParams: Record , token: string, currency: string): SearchHotelsOptions { const { city, startDate, endDate } = castQueryParams(queryParams); if (typeof city !== 'string' || typeof startDate !== 'string' || typeof endDate !== 'string') { throw new Error('Invalid search flight options'); } const limit = !isNaN(Number(queryParams['limit'])) ? Number(queryParams['limit']) : 20; return { location: city, checkIn: startDate, checkOut: endDate, currency: currency.toLowerCase(), limit, token, }; }
Создадим сервисы:
mkdir src/app/search/hotels/services mkdir src/app/search/hotels/services/lib echo >src/app/search/hotels/services/index.ts
Реализация:
import { HttpClient } from '@angular/common/http'; import { DEFAULT_CURRENCY_CODE, inject, Injectable, LOCALE_ID, TransferState } from '@angular/core'; import type { Observable } from 'rxjs'; import { map } from 'rxjs'; import type { Environment } from '@baf/core'; import { castParams, ENV_DEFAULT, ENV_KEY } from '@baf/core'; import type { SearchHotel, SearchHotelDetails, SearchHotelDto, SearchHotelInfo, SearchHotelsDetailsResponse, SearchHotelsResponse, } from '@baf/search/hotels/common'; import { getSearchHotelsInfoOptions, getSearchHotelsOptions } from '@baf/search/hotels/common'; @Injectable() export class SearchHotelService { private readonly httpClient = inject(HttpClient); private readonly environment = inject(TransferState).get(ENV_KEY, ENV_DEFAULT); private readonly localeId = inject(LOCALE_ID); private readonly currency = inject(DEFAULT_CURRENCY_CODE); findHotels(queryParams: Record ): Observable { const params = castParams(getSearchHotelsOptions(queryParams, this.environment.hotellookToken, this.currency)); return this.httpClient.get ('/api/hotels/cache.json', { params }).pipe( map((response) => { // На фронте так делать не нужно. Должен быть бэк, где будет собираться данные и кешироваться. // Это только для примера. return response.map((hotel) => ({ ...hotel, photos: [ { url: `https://photo.hotellook.com/image_v2/limit/h${hotel.hotelId}_0/320/240.auto`, width: 320, height: 240, }, { url: `https://photo.hotellook.com/image_v2/limit/h${hotel.hotelId}_1/320/240.auto`, width: 320, height: 240, }, { url: `https://photo.hotellook.com/image_v2/limit/h${hotel.hotelId}_2/320/240.auto`, width: 320, height: 240, }, { url: `https://photo.hotellook.com/image_v2/limit/h${hotel.hotelId}_3/320/240.auto`, width: 320, height: 240, }, { url: `https://photo.hotellook.com/image_v2/limit/h${hotel.hotelId}_4/320/240.auto`, width: 320, height: 240, }, { url: `https://photo.hotellook.com/image_v2/limit/h${hotel.hotelId}_5/320/240.auto`, width: 320, height: 240, }, ], })); }), ); } findHotelsInfo(queryParams: Record ): Observable { const params = castParams(getSearchHotelsInfoOptions(queryParams, this.localeId)); return this.httpClient.get ('/api/hotels/lookup.json', { params }).pipe(map(({ results }) => results.hotels)); } getHotelsDetails(locationId: number): Observable { const params = { locationId, token: this.environment.hotellookToken, }; return this.httpClient.get ('/api/hotels/static/hotels.json', { params }).pipe(map(({ hotels }) => hotels)); } }
Добавим раздел:
mkdir src/app/search/hotels/ui mkdir src/app/search/hotels/ui/forms mkdir src/app/search/hotels/ui/forms/lib echo >src/app/search/hotels/ui/forms/index.ts
Сгененируем компонент и изменим его:
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { PATHS } from '@baf/core'; import type { SearchFormOptions } from '@baf/search/common'; import type { SearchHotelForm } from '@baf/search/hotels/common'; import { initialSearchHotelFormGroup } from '@baf/search/hotels/common'; import { SearchDateComponent, SearchDestinationComponent, SearchGroupComponent, SearchPassengersComponent, SearchReverseComponent, } from '@baf/search/ui/fields'; import { SearchFormComponent } from '@baf/search/ui/form'; import { ButtonComponent } from '@baf/ui/buttons'; @Component({ selector: 'baf-search-hotel-form', standalone: true, imports: [ SearchFormComponent, SearchGroupComponent, SearchDestinationComponent, SearchReverseComponent, SearchDateComponent, SearchPassengersComponent, ButtonComponent, ], templateUrl: './search-hotel-form.component.html', styleUrl: './search-hotel-form.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchHotelFormComponent { readonly form = initialSearchHotelFormGroup; readonly redirectTo = PATHS.searchHotel; readonly options: SearchFormOptions= { city: { label: $localize`:Search Field:City`, id: 'city', types: ['city'], key: 'name' }, startDate: { label: $localize`:Search Field:When`, id: 'startDate' }, endDate: { label: $localize`:Search Field:When back`, id: 'endDate', startDate: this.form.controls.startDate }, passengers: { label: $localize`:Search Field:Guests`, id: 'passengers' }, }; }
Внимательный читатель заметит, что реализация полностью продублирована из формы поиска авиабилетов.
Реализуем форму фильтров:
mkdir src/app/search/hotels/ui/filters mkdir src/app/search/hotels/ui/filters/lib echo >src/app/search/hotels/ui/filters/index.ts
Разметка:
Логика:
import { ChangeDetectionStrategy, Component } from '@angular/core'; import type { SearchFormOptions } from '@baf/search/common'; import type { SearchHotelFilters } from '@baf/search/hotels/common'; import { initialSearchHotelFiltersGroup } from '@baf/search/hotels/common'; import { SearchFiltersComponent } from '@baf/search/ui/filters'; import { FilterBreakfastComponent } from './filter-breakfast/filter-breakfast.component'; import { FilterFiveStarsComponent } from './filter-five-stars/filter-five-stars.component'; import { FilterFreeCancellationComponent } from './filter-free-cancellation/filter-free-cancellation.component'; @Component({ selector: 'baf-search-filters-hotels', standalone: true, imports: [SearchFiltersComponent, FilterBreakfastComponent, FilterFreeCancellationComponent, FilterFiveStarsComponent], templateUrl: './search-filters-hotels.component.html', styleUrl: './search-filters-hotels.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchFiltersHotelsComponent { readonly form = initialSearchHotelFiltersGroup; readonly options: SearchFormOptions= { breakfast: { label: $localize`:Search Filter:Breakfast`, id: 'breakfast', name: 'breakfast' }, fiveStars: { label: $localize`:Search Filter:Five Stars`, id: 'fiveStars', name: 'fiveStars' }, freeCancellation: { label: $localize`:Search Filter:Free Cancellation`, id: 'freeCancellation', name: 'freeCancellation' }, }; }
Выведем на странице:
{ path: PATHS.homeHotels, title: $localize`:Home Title:Buy & Fly - Hotels with 10% cashback`, loadComponent: () => import('@baf/home/page').then((m) => m.HomePageComponent), children: [ { path: '', loadComponent: () => import('@baf/search/hotels/ui/form').then((m) => m.SearchHotelFormComponent), outlet: 'form', }, ], },
Запустим проект:
Продублируем все для поиска ж/д билетов и включим.
{ path: PATHS.homeRailways, title: $localize`:Home Title:Buy & Fly - Railways with 5% cashback`, loadComponent: () => import('@baf/home/page').then((m) => m.HomePageComponent), children: [ { path: '', loadComponent: () => import('@baf/search/railways/ui/form').then((m) => m.SearchRailwayFormComponent), outlet: 'form', }, ], },
Запустим проект:
Для добавления русского языка добавим переводы:
В angular.json:
{ "i18n": { "sourceLocale": "en-US", "locales": { "ru": { "translation": "src/i18n/messages.xlf", "baseHref": "" } } } }
Запустим команду:
yarn ng extract-i18n --out-file=src/i18n/source.xlf
Заполним файл:
src/i18n/messages.xlf
Добавим страницу поиска:
mkdir src/app/search/page mkdir src/app/search/page/lib echo >src/app/search/page//index.ts
Создадим компонент search-page:
yarn ng g c search-page
Шаблон:
Немного стилей:
@use 'src/stylesheets/device' as device; .row { display: flex; flex-direction: column-reverse; @include device.media-tablet-up() { flex-direction: row; } } .column { @include device.media-tablet-up() { &:first-child { width: 33.333%; padding-right: 0.5rem; } &:last-child { width: 66.667%; padding-left: 0.5rem; } } @include device.media-web() { &:first-child { width: 25%; } &:last-child { width: 75%; } } } .form { margin: 1rem 0; }
Компонент:
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { ContainerComponent } from '@baf/ui/container'; @Component({ selector: 'baf-search-page', standalone: true, imports: [RouterOutlet, ContainerComponent], templateUrl: './search-page.component.html', styleUrl: './search-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchPageComponent {}
Как можно увидеть из макета, на странице выводится вложенные компоненты из роутинга:
{ path: PATHS.search, loadChildren: () => import('./routes/search.routes').then((m) => m.searchRoutes), },
И роуты:
import type { Routes } from '@angular/router'; import { PATHS, withChildNavigation } from '@baf/core'; export const searchRoutes: Routes = [ { path: PATHS.searchAvia, title: $localize`:Search Page:Search for cheap flights`, loadComponent: () => import('@baf/search/page').then((m) => m.SearchPageComponent), children: [ { path: '', loadComponent: () => import('@baf/search/avia/ui/form').then((m) => m.SearchAviaFormComponent), outlet: 'form', }, { path: '', loadComponent: () => import('@baf/search/avia/ui/results').then((m) => m.SearchResultsAviaComponent), outlet: 'results', }, { path: '', loadComponent: () => import('@baf/search/avia/ui/filters').then((m) => m.SearchFiltersAviaComponent), outlet: 'filters', }, ], }, { path: PATHS.searchHotel, title: $localize`:Search Page:Search for cheap hotels`, loadComponent: () => import('@baf/search/page').then((m) => m.SearchPageComponent), children: [ { path: '', loadComponent: () => import('@baf/search/hotels/ui/form').then((m) => m.SearchHotelFormComponent), outlet: 'form', }, { path: '', loadComponent: () => import('@baf/search/hotels/ui/results').then((m) => m.SearchHotelsResultComponent), outlet: 'results', }, { path: '', loadComponent: () => import('@baf/search/hotels/ui/filters').then((m) => m.SearchFiltersHotelsComponent), outlet: 'filters', }, ], }, { path: PATHS.searchTour, title: $localize`:Search Page:Search for cheap tours`, loadComponent: () => import('@baf/development/page').then((m) => m.DevelopmentPageComponent), }, { path: PATHS.searchRailway, title: $localize`:Search Page:Search for cheap railways`, loadComponent: () => import('@baf/development/page').then((m) => m.DevelopmentPageComponent), }, ].map(withChildNavigation(PATHS.search));
В ходе цикла статей было реализовано приложение для поиска авиабилетов, а также бронирования отелей.
Я описал весь процесс создания, начиная с генерации приложения, заканчивая интеграция со сторонним API.
Демо можно посмотреть здесь - buy-and-fly.fafn.ru/
Спасибо, что дочитали до конца.
Все исходники находятся на github, в репозитории - github.com/Fafnur/buy-and-fly
Демо можно посмотреть здесь - buy-and-fly.fafn.ru/
Мои группы: telegram, medium, vk, x.com, linkedin, site
부인 성명: 제공된 모든 리소스는 부분적으로 인터넷에서 가져온 것입니다. 귀하의 저작권이나 기타 권리 및 이익이 침해된 경우 자세한 이유를 설명하고 저작권 또는 권리 및 이익에 대한 증거를 제공한 후 이메일([email protected])로 보내주십시오. 최대한 빨리 처리해 드리겠습니다.
Copyright© 2022 湘ICP备2022001581号-3