」工欲善其事,必先利其器。「—孔子《論語.錄靈公》
首頁 > 程式設計 > Angular 18 中搜尋的實作以及與外部 API 的集成

Angular 18 中搜尋的實作以及與外部 API 的集成

發佈於2024-11-08
瀏覽:843

Перейдем к созданию формы поиска билетов и жилья.

Посмотрим пример на сайте - travel.alfabank.ru

Там представлены следующие поля:

  • origin - откуда;
  • destination - куда;
  • direct - прямой маршрут;
  • currency - валюта;
  • departure_at - дата вылета/отправления/заселения;
  • return_at - дата возвращения/выселения.

При поиске авиабилетов, будут все поля, а в случае подбора отеля только их часть.

Невозможно забронировать жилье в разных местах (вот такая негибкая система, хотя в 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;
}
  • SearchDestination - описывает место назначения.
    • type - это страна, город или аэропорт
    • name - имя
  • SearchFieldOptions - параметры, которые можно задать при конфигурировании формы.
  • SearchFormOptions - генерируемый тип
  • getSearchQueryParams - небольшая утилита для приведения queryParams к требуемому виду.

Начнем реализацию формы с поиска авиабилетов, так как это включает весь набор полей.

Создадим раздел 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',
  };
}
  • SearchDeclination - склонения названия места (в основном нужно только в русском языке);
  • SearchCityOrAirportDTO - информация о городе или аэропорте;
  • SearchFlightOptions - опции поиска;
  • SearchFlightResponse - подходящие рейсы;
  • SearchAviaLine - одно направление;
  • getSearchFlightOptions - функция, которая проверяет переданные параметры.

Теперь зададим саму форму:

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)],
  }),
});
  • SearchAviaForm - список полей;
  • SearchAviaFormGroup - angular reactive form;
  • initialSearchAviaFormGroup - начальное состояние.

И также, определим фильтры:

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:

  • getSearchFlightOptions - перевод параметров в формат запроса;
  • castParams - удаляет ненужные и пустые свойства.

Настройка проски

Для локальной разработки нужно настроить прокси.

Установим 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

Нам потребуются следующие контролы:

  • date - выбор даты;
  • destination - выбор места;
  • passengers - указание количества пассажиров.

Также 2 специальных компонента:

  • group - группировка полей;
  • reverse - смена откуда и куда.

Создание SearchDate

Запустим команду:

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);
    }
  }
}
  • is-hide - скрываем на мобильном устройстве обратную дату, и показываем ее если выбрано значение;
  • is-start-date/is-end-date - скругления у полей.

Реализация самого компонента достаточно тривиальна.

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. Я все экспериментирую с автоматизацией стилизации. Но видимо, это не самое удачное решение.

  • control - контрол формы;
  • options - список настроек.

Создание SearchDestination

Создадим место назначения:

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. Как это обойти расскажу ниже.

Создание SearchReverse

Добавим смену места.

Для этого создадим новый компонент:

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 });
    }
  }
}

Просто при клике меняем места назначения.

Создание SearchPassengers

Контрол для количества пассажиров:

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();
}

Создание SearchGroup

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() }),
    });
  }
}

Форма добавляет общий метод отправки и после инициализации компонента заполняется данными.

  • form - angular reactive form;
  • redirectTo - редирект на результаты;
  • submitted - событие перехода.
    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'), 

Запустим проект:

Реализация поиска и интеграция с внешним API в Angular 18

Форма поиска отелей

Создадим форму для бронирования отелей:

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;
  }[];
}
  • SearchLocation - информация о местоположении;
  • SearchHotelInfo - информация об отеле;
  • SearchHotelsResponse - список отелей;
  • SearchHotelDto - DTO;
  • SearchHotelDetails - расширенная информация
  • SearchHotelsDetailsResponse - список отелей
  • SearchHotel - информация об отеле;

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: [] }),
});
  • SearchHotelFilters - доступные параметры для фильтрации
  • SearchHotelFiltersGroup - angular reactive form
  • initialSearchHotelFiltersGroup - начальное состояние

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,
  };
}
  • SearchHotelsOptions, SearchHotelsInfoOptions - информация об отеле;
  • getSearchHotelsOptions, getSearchHotelsInfoOptions - формирование опций для внешнего API.

Создадим сервисы:

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));
  }
}
  • findHotels - получение списка отелей по заданным параметрам;
  • findHotelsInfo, getHotelsDetails - не используется в проекте, осталось как часть легаси.

Добавим раздел:

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',
      },
    ],
  },

Запустим проект:

Реализация поиска и интеграция с внешним API в Angular 18

Форма поиска Ж/Д билетов

Продублируем все для поиска ж/д билетов и включим.

 {
    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',
      },
    ],
  },

Запустим проект:

Реализация поиска и интеграция с внешним API в Angular 18

Локализация

Для добавления русского языка добавим переводы:

В 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

版本聲明 本文轉載於:https://dev.to/fafnur/riealizatsiia-poiska-i-intieghratsiia-s-vnieshnim-api-v-angular-18-1l35?1如有侵犯,請聯絡[email protected]刪除
最新教學 更多>
  • 為什麼PHP的DateTime :: Modify('+1個月')會產生意外的結果?
    為什麼PHP的DateTime :: Modify('+1個月')會產生意外的結果?
    使用php dateTime修改月份:發現預期的行為在使用PHP的DateTime類時,添加或減去幾個月可能並不總是會產生預期的結果。正如文檔所警告的那樣,“當心”這些操作的“不像看起來那樣直觀。 考慮文檔中給出的示例:這是內部發生的事情: 現在在3月3日添加另一個月,因為2月在2001年只有2...
    程式設計 發佈於2025-04-08
  • 為什麼PYTZ最初顯示出意外的時區偏移?
    為什麼PYTZ最初顯示出意外的時區偏移?
    與pytz 最初從pytz獲得特定的偏移。例如,亞洲/hong_kong最初顯示一個七個小時37分鐘的偏移: 差異源利用本地化將時區分配給日期,使用了適當的時區名稱和偏移量。但是,直接使用DateTime構造器分配時區不允許進行正確的調整。 example pytz.timezone(&#...
    程式設計 發佈於2025-04-08
  • 哪種在JavaScript中聲明多個變量的方法更可維護?
    哪種在JavaScript中聲明多個變量的方法更可維護?
    在JavaScript中聲明多個變量:探索兩個方法在JavaScript中,開發人員經常遇到需要聲明多個變量的需要。對此的兩種常見方法是:在單獨的行上聲明每個變量: 當涉及性能時,這兩種方法本質上都是等效的。但是,可維護性可能會有所不同。 第一個方法被認為更易於維護。每個聲明都是其自己的語句,使...
    程式設計 發佈於2025-04-08
  • 如何使用Java.net.urlConnection和Multipart/form-data編碼使用其他參數上傳文件?
    如何使用Java.net.urlConnection和Multipart/form-data編碼使用其他參數上傳文件?
    使用http request 上傳文件上傳到http server,同時也提交其他參數,java.net.net.urlconnection and Multipart/form-data Encoding是普遍的。 Here's a breakdown of the process:Mu...
    程式設計 發佈於2025-04-08
  • 如何有效地選擇熊貓數據框中的列?
    如何有效地選擇熊貓數據框中的列?
    在處理數據操作任務時,在Pandas DataFrames 中選擇列,選擇特定列的必要。在Pandas中,選擇列的各種選項。 選項1:使用列名 如果已知列索引,請使用ILOC函數選擇它們。請注意,python索引基於零。 df1 = df.iloc [:,0:2]#使用索引0和1 的 colum...
    程式設計 發佈於2025-04-08
  • 如何使用PHP將斑點(圖像)正確插入MySQL?
    如何使用PHP將斑點(圖像)正確插入MySQL?
    essue VALUES('$this->image_id','file_get_contents($tmp_image)')";This code builds a string in PHP, but the function call fil...
    程式設計 發佈於2025-04-08
  • 如何使用node-mysql在單個查詢中執行多個SQL語句?
    如何使用node-mysql在單個查詢中執行多個SQL語句?
    Multi-Statement Query Support in Node-MySQLIn Node.js, the question arises when executing multiple SQL statements in a single query using the node-mys...
    程式設計 發佈於2025-04-08
  • 為什麼儘管有效代碼,為什麼在PHP中捕獲輸入?
    為什麼儘管有效代碼,為什麼在PHP中捕獲輸入?
    在php ;?>" method="post">The intention is to capture the input from the text box and display it when the submit button is clicked.但是,輸出...
    程式設計 發佈於2025-04-08
  • HTML格式標籤
    HTML格式標籤
    HTML 格式化元素 **HTML Formatting is a process of formatting text for better look and feel. HTML provides us ability to format text without us...
    程式設計 發佈於2025-04-08
  • 如何配置Pytesseract以使用數字輸出的單位數字識別?
    如何配置Pytesseract以使用數字輸出的單位數字識別?
    Pytesseract OCR具有單位數字識別和僅數字約束 在pytesseract的上下文中,在配置tesseract以識別單位數字和限制單個數字和限制輸出對數字可能會提出質疑。 To address this issue, we delve into the specifics of Te...
    程式設計 發佈於2025-04-08
  • 如何使用Python理解有效地創建字典?
    如何使用Python理解有效地創建字典?
    在python中,詞典綜合提供了一種生成新詞典的簡潔方法。儘管它們與列表綜合相似,但存在一些顯著差異。 與問題所暗示的不同,您無法為鑰匙創建字典理解。您必須明確指定鍵和值。 For example:d = {n: n**2 for n in range(5)}This creates a dict...
    程式設計 發佈於2025-04-08
  • 如何在JavaScript對像中動態設置鍵?
    如何在JavaScript對像中動態設置鍵?
    在嘗試為JavaScript對象創建動態鍵時,如何使用此Syntax jsObj['key' i] = 'example' 1;不工作。正確的方法採用方括號: jsobj ['key''i] ='example'1; 在JavaScript中,數組是一...
    程式設計 發佈於2025-04-08
  • 如何從PHP中的數組中提取隨機元素?
    如何從PHP中的數組中提取隨機元素?
    從陣列中的隨機選擇,可以輕鬆從數組中獲取隨機項目。考慮以下數組:; 從此數組中檢索一個隨機項目,利用array_rand( array_rand()函數從數組返回一個隨機鍵。通過將$項目數組索引使用此鍵,我們可以從數組中訪問一個隨機元素。這種方法為選擇隨機項目提供了一種直接且可靠的方法。
    程式設計 發佈於2025-04-08
  • 您可以使用CSS在Chrome和Firefox中染色控制台輸出嗎?
    您可以使用CSS在Chrome和Firefox中染色控制台輸出嗎?
    在javascript console 中顯示顏色是可以使用chrome的控制台顯示彩色文本,例如紅色的redors,for for for for錯誤消息? 回答是的,可以使用CSS將顏色添加到Chrome和Firefox中的控制台顯示的消息(版本31或更高版本)中。要實現這一目標,請使用以下...
    程式設計 發佈於2025-04-08
  • 如何克服PHP的功能重新定義限制?
    如何克服PHP的功能重新定義限制?
    克服PHP的函數重新定義限制 但是,PHP工具腰帶中有一個隱藏的寶石:runkit擴展。它使您能夠靈活地重新定義函數。 runkit_function_renction_rename() runkit_function_redefine() //重新定義'this'以返回“新和...
    程式設計 發佈於2025-04-08

免責聲明: 提供的所有資源部分來自互聯網,如果有侵犯您的版權或其他權益,請說明詳細緣由並提供版權或權益證明然後發到郵箱:[email protected] 我們會在第一時間內為您處理。

Copyright© 2022 湘ICP备2022001581号-3