Angular 18에서 검색 구현 및 외부 API와의 통합

DDD
풀어 주다: 2024-09-13 22:18:32
원래의
381명이 탐색했습니다.

티켓 및 숙박 검색 양식을 만들어 보겠습니다.

travel.alfabank.ru 웹사이트에서 예를 살펴보세요

여기에는 다음 필드가 표시됩니다.

  • 원산지 - 어디서;
  • 목적지 - 어디;
  • 직행-직행 경로;
  • 통화-통화;
  • 출발_at - 출발/출발/체크인 날짜;
  • return_at - 반환/퇴거 날짜.

항공권 검색 시 모든 항목이 표시되며, 호텔 선택 시 일부 항목만 표시됩니다.

다른 장소에서 숙소를 예약하는 것은 불가능합니다(에어비앤비에서 제공하지만 이는 매우 경직된 시스템입니다).

검색과 관련된 모든 내용을 저장할 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<T> = {
  readonly [P in keyof T]: SearchFieldOptions;
};

export function getSearchQueryParams(
  form: Readonly<Record<string, string | number | boolean | Record<string, unknown>>>,
): Record<string, unknown> {
  const params: Record<string, unknown> = {};

  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 - 목적지를 설명합니다.
    • 유형은 국가, 도시 또는 공항입니다
    • 이름 - 이름
  • SearchFieldOptions - 양식 구성 시 설정할 수 있는 매개변수입니다.
  • SearchFormOptions - 생성된 유형
  • getSearchQueryParams는 queryParams를 필요한 형식으로 가져오는 작은 유틸리티입니다.

항공권 검색에 전체 필드 집합이 포함되므로 양식을 검색하여 구현해 보겠습니다.

항공 섹션 만들기:

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<string, unknown>, 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<FormFor<SearchAviaForm>>;

export const initialSearchAviaFormGroup: SearchAviaFormGroup = new FormGroup({
  from: new FormControl<string | SearchDestination>('', {
    nonNullable: true,
    validators: [Validators.required],
  }),
  to: new FormControl<string | SearchDestination>('', {
    nonNullable: true,
    validators: [Validators.required],
  }),
  startDate: new FormControl<string>('', {
    nonNullable: true,
    validators: [Validators.required],
  }),
  endDate: new FormControl<string>('', {
    nonNullable: true,
    validators: [],
  }),
  passengers: new FormControl<number>(1, {
    nonNullable: true,
    validators: [Validators.required, Validators.min(1), Validators.max(20)],
  }),
});
로그인 후 복사
  • SearchAviaForm - 필드 목록
  • SearchAviaFormGroup - 각도 반응형
  • 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<FormFor<SearchAviaFilters>>;

export const initialSearchAviaFiltersGroup: SearchAviaFiltersGroup = new FormGroup({
  baggage: new FormControl(false, { nonNullable: true, validators: [] }),
  direct: new FormControl(false, { nonNullable: true, validators: [] }),
});
로그인 후 복사

SearchAviaFilters - 사용 가능한 값;
SearchAviaFiltersGroup - 각도 반응 형식;
initialSearchAviaFiltersGroup - 초기 상태.

외부 API에 액세스하기 위한 서비스가 포함될 검색/avia/services 섹션을 추가하세요.

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<Environment>(ENV_KEY, ENV_DEFAULT);
  private readonly currency = inject(DEFAULT_CURRENCY_CODE);

  findFlights(queryParams: Record<string, unknown>): Observable<SearchFlight[]> {
    const params = castParams(getSearchFlightOptions(queryParams, this.environment.aviasalesToken, this.currency));

    return this.httpClient.get<SearchFlightResponse>('/api/aviasales/v3/prices_for_dates', { params }).pipe(map(({ data }) => data));
  }
}
로그인 후 복사

SearchAviaService에는 단 하나의 메소드(findFlights:

)만 포함되어 있습니다.
  • getSearchFlightOptions - 매개변수를 요청 형식으로 변환합니다.
  • CastParams - 불필요하고 빈 속성을 제거합니다.

프로스키 세팅

로컬 개발을 위해서는 프록시를 구성해야 합니다.

dotenv 설치:

yarn add -D dotenv
로그인 후 복사

그런 다음 env를 연결하여 main.server.ts에서:

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에서 env를 연결합니다.

export const config = mergeApplicationConfig(envConfig, appConfig, serverConfig);
로그인 후 복사

프로젝트 루트에 다음 토큰과 함께 .env를 추가합니다.

AVIASALES_TOKEN=YourTokenForTravelPayouts
HOTELLOOK_TOKEN=YourTokenForTravelPayouts
로그인 후 복사

실행하고 테스트해 보겠습니다.

콘솔에 변수를 표시할 수 있습니다.

제작을 위해 노드 서버 기반으로 프록시를 추가했습니다
서버.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:

<baf-datepicker [control]="control()" [options]="options()"></baf-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<string>;
}

@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<string>, FormControl<string>>({
    transform: (value) => {
      this.extraClassService.register('valid', value.valueChanges, () => {
        this.extraClassService.patch('is-valid', value.valid);
      });

      return value;
    },
  });

  readonly options = input.required<DatepickerOptions, SearchDateOptions>({
    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
로그인 후 복사

Шаблон:

<baf-autocomplete [control]="control()" [options]="options()" [data]="data$"></baf-autocomplete>
로그인 후 복사

Немного стилей:

@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<FormControl<string | SearchDestination>>();
  readonly options = input.required<AutocompleteOptions & SearchDestinationOptions, SearchDestinationOptions>({
    transform: (options) => {
      this.extraClassService.update('options', toClass(options.id));

      return {
        ...options,
        key: options.key ?? 'code',
        displayFn: (item: SearchDestination) => {
          return `${item.name}, ${item.code}<br>${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<SearchDestination[]>([]);

  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<SearchDestination[]> {
    const withTypes = types?.length ? `&${types.map((type) => `types[]=${type}`).join('&')}` : '';

    return this.httpClient.get<SearchDestination[]>(`/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
로그인 후 복사

В шаблоне выведем кнопку с иконкой:

<button baf-icon-button type="button" (click)="onReverse()" i18n-aria-label="Search Field|Swap" aria-label="Swap"><baf-sync-alt /></button>
로그인 후 복사

Немного стилей:

@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<FormGroup>();

  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
로그인 후 복사

Макет:

<baf-input-control>
  <label baf-label [attr.for]="options().id">{{ options().label }}</label>
  <input [id]="options().id" baf-input type="number" [formControl]="control()" [placeholder]="options().placeholder ?? ''" />
</baf-input-control>
로그인 후 복사

Логика:

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<FormControl<number | undefined>>();
  readonly options = input.required<SearchPassengersOptions>();
}
로그인 후 복사

Создание 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: '<ng-content/>',
  styleUrl: './search-group.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [ExtraClassService],
})
export class SearchGroupComponent {
  private readonly extraClassService = inject(ExtraClassService);

  readonly mode = input<SearchGroupType, SearchGroupType>(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
로그인 후 복사
로그인 후 복사

В шаблон вставим содержимое:

<form [formGroup]="form()" (ngSubmit)="onSubmit()">
  <ng-content />
  <baf-search-group mode="submit">
    <button baf-button bafMode="primary" bafWidth="max" type="submit" i18n="Search Form|Find">Find</button>
  </baf-search-group>
</form>
로그인 후 복사

Немного стилей:

@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<FormGroup>();
  readonly redirectTo = input.required<PathValues>();
  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
로그인 후 복사

Шаблон:

<baf-card>
  <ng-content />
  <button type="button" baf-button bafMode="primary" bafSize="medium" bafWidth="max" i18n="Search Filters|Apply" (click)="onApply()">
    Apply
  </button>
  <button type="button" baf-button bafMode="tertiary" bafSize="medium" bafWidth="max" i18n="Search Filters|Reset" (click)="onReset()">
    Reset
  </button>
</baf-card>
로그인 후 복사

Немного стилей:

@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<FormGroup>();
  readonly redirectTo = input.required<PathValues>();
  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<SearchAviaForm> = {
    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' },
  };
}
로그인 후 복사

Вывдем все:

<baf-search-form [redirectTo]="redirectTo" [form]="form">
  <baf-search-group mode="destination">
    <baf-search-destination [control]="form.controls.from" [options]="options.from" />
    <baf-search-reverse [form]="form" />
    <baf-search-destination [control]="form.controls.to" [options]="options.to" />
  </baf-search-group>
  <baf-search-group mode="line">
    <baf-search-group mode="date">
      <baf-search-date [control]="form.controls.startDate" [options]="options.startDate" />
      <baf-search-date [control]="form.controls.endDate" [options]="options.endDate" />
    </baf-search-group>
    <baf-search-passengers [control]="form.controls.passengers" [options]="options.passengers" />
  </baf-search-group>
</baf-search-form>
로그인 후 복사

Как можно заметить, вся бизнес логика скрыта в дочерних компонентах.

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<SearchAviaFilters> = {
    baggage: { label: $localize`:Search Filter:Baggage`, id: 'baggage', name: 'baggage' },
    direct: { label: $localize`:Search Filter:Direct`, id: 'direct', name: 'direct' },
  };
}
로그인 후 복사

И шаблон:

<baf-search-filters [form]="form">
  <baf-filter-baggage [control]="form.controls.baggage" [options]="options.baggage" />
  <baf-filter-direct [control]="form.controls.direct" [options]="options.direct"/>
</baf-search-filters>
로그인 후 복사

Из примера видно, что выводятся просто фильтры списком. Как и в случае с основной формой, вся логика вынесена в дочерние компоненты.

Пример фильтра:

<baf-checkbox [control]="control()" [options]="options()">{{ options().label }}</baf-checkbox>
로그인 후 복사
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<FormControl<boolean>>();
  readonly options = input.required<FilterDirectOptions>();
}
로그인 후 복사

Подключение формы

Теперь можно вывести форму на главной странице:

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<string, number>;
  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<string, number>;
  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<string, string>;
  readonly address: Record<string, string>;
  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<FormFor<SearchHotelFilters>>;

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<FormFor<SearchHotelForm>>;

export const initialSearchHotelFormGroup: SearchHotelFormGroup = new FormGroup({
  city: new FormControl<string | SearchDestination>('', {
    nonNullable: true,
    validators: [Validators.required],
  }),
  startDate: new FormControl<string>('', {
    nonNullable: true,
    validators: [Validators.required],
  }),
  endDate: new FormControl<string>('', {
    nonNullable: true,
    validators: [],
  }),
  passengers: new FormControl<number | undefined>(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<string, unknown>, 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<string, unknown>, 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<Environment>(ENV_KEY, ENV_DEFAULT);
  private readonly localeId = inject(LOCALE_ID);
  private readonly currency = inject(DEFAULT_CURRENCY_CODE);

  findHotels(queryParams: Record<string, unknown>): Observable<SearchHotel[]> {
    const params = castParams(getSearchHotelsOptions(queryParams, this.environment.hotellookToken, this.currency));

    return this.httpClient.get<SearchHotelDto[]>('/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<string, unknown>): Observable<SearchHotelInfo[]> {
    const params = castParams(getSearchHotelsInfoOptions(queryParams, this.localeId));

    return this.httpClient.get<SearchHotelsResponse>('/api/hotels/lookup.json', { params }).pipe(map(({ results }) => results.hotels));
  }

  getHotelsDetails(locationId: number): Observable<SearchHotelDetails[]> {
    const params = {
      locationId,
      token: this.environment.hotellookToken,
    };

    return this.httpClient.get<SearchHotelsDetailsResponse>('/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
로그인 후 복사

Сгененируем компонент и изменим его:

<baf-search-form [redirectTo]="redirectTo" [form]="form">
  <baf-search-group mode="single">
    <baf-search-destination [control]="form.controls.city" [options]="options.city" />
  </baf-search-group>
  <baf-search-group mode="line">
    <baf-search-group mode="date">
      <baf-search-date [control]="form.controls.startDate" [options]="options.startDate" />
      <baf-search-date [control]="form.controls.endDate" [options]="options.endDate" />
    </baf-search-group>
    <baf-search-passengers [control]="form.controls.passengers" [options]="options.passengers" />
  </baf-search-group>
</baf-search-form>
로그인 후 복사
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<SearchHotelForm> = {
    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
로그인 후 복사

Разметка:

<baf-search-filters [form]="form">
  <baf-filter-breakfast [control]="form.controls.breakfast" [options]="options.breakfast"></baf-filter-breakfast>
  <baf-filter-five-stars [control]="form.controls.fiveStars" [options]="options.fiveStars"></baf-filter-five-stars>
  <baf-filter-free-cancellation
    [control]="form.controls.freeCancellation"
    [options]="options.freeCancellation"
  ></baf-filter-free-cancellation>
</baf-search-filters>
로그인 후 복사

Логика:

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<SearchHotelFilters> = {
    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
로그인 후 복사

Шаблон:

<baf-container>
  <div class="form">
    <router-outlet name="form" />
  </div>
  <div class="row">
    <div class="column">
      <router-outlet name="filters" />
    </div>
    <div class="column">
      <router-outlet name="results" />
      <router-outlet name="map" />
    </div>
  </div>
  <router-outlet />
</baf-container>
로그인 후 복사

Немного стилей:

@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

위 내용은 Angular 18에서 검색 구현 및 외부 API와의 통합의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

원천:dev.to
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿
회사 소개 부인 성명 Sitemap
PHP 중국어 웹사이트:공공복지 온라인 PHP 교육,PHP 학습자의 빠른 성장을 도와주세요!