Home > Web Front-end > JS Tutorial > body text

Development of core services and utilities in Angular 18

DDD
Release: 2024-09-13 22:19:07
Original
899 people have browsed it

Разработка core сервисов и утилит в Angular 18

Core is a set of functions, configs and utilities that are used in the application.

Usually, the core is what you drag from project to project. In this case, I tried to take only what was necessary.

Briefly about everything core:

  • api/params.ts - utility for setting parameters. For example, in query params there is baggage=true, but true needs to be not a string, but a boolean;
  • currency/currency.ts — currency config;
  • env/environment.ts - getting and using .env;
  • form/form.ts - a little typing of FormGroup;
  • form/extract-changes.directive.ts - change detection directive in FormControl;
  • hammer/hammer.ts - hammerjs settings;
  • interceptors/content-type.interceptor.ts - determining the Content-Type for HTTP requests;
  • metrics - service for sending events to google analytics and yandex metrics;
  • navigation/mavigation.ts - implementation of paths in the application;
  • styles/extra-class.service.ts - my bike for hanging classes;
  • types/type.ts - custom types;
  • utils - utilities for working with dates and deadlines.

Only navigation deserves special attention.

Managing app navigation

Let's take a closer look at the implementation of navigation controls.

Suppose we have routes for the entire application:

export const PATHS = {
  home: '',
  homeAvia: '',
  homeHotels: 'hotels',
  homeTours: 'tours',
  homeRailways: 'railways',

  rules: 'rules',
  terms: 'terms',
  documents: 'documents',
  faq: 'faq',
  cards: 'cards',
  login: 'login',
  registration: 'registration',

  notFound: 'not-found',
  serverError: 'server-error',
  permissionDenied: 'permission-denied',

  search: 'search',
  searchAvia: 'search/avia',
  searchHotel: 'search/hotels',
  searchTour: 'search/tours',
  searchRailway: 'search/railways',
} as const;
Copy after login

PathValues ​​- a type that will represent a set of strings.

export type PathValues = (typeof PATHS)[keyof typeof PATHS];

type Filter<T extends string> = T extends `:${infer Param}` ? Param : never;

type Split<Value extends string> = Value extends `${infer LValue}/${infer RValue}` ? Filter<LValue> | Split<RValue> : Filter<Value>;

export type GetPathParams<T extends string> = {
  [key in Split<T>]: string | number;
};

export interface NavigationLink<T extends PathValues = PathValues> {
  readonly label: string;
  readonly route: T;
  readonly params?: GetPathParams<T>;
  readonly suffix?: string;
}
Copy after login

NavigationLink - link array interface.

getRoute splits the string with parameters and replaces the found keys with the passed values:

export function getRoute<T extends PathValues>(path: T, params: Record<string, string | number> = {}): (string | number)[] {
  const segments = path.split('/').filter((value) => value?.length);
  const routeWithParams: (string | number)[] = ['/'];

  for (const segment of segments) {
    if (segment.charAt(0) === ':') {
      const paramName = segment.slice(1);
      if (params && params[paramName]) {
        routeWithParams.push(params[paramName]);
      } else {
        routeWithParams.push(paramName);
      }
    } else {
      routeWithParams.push(segment);
    }
  }

  return routeWithParams;
}
Copy after login

Latest functions for “shaping” paths in application routes:

export function getChildPath(path: PathValues, parent: PathValues): string {
  return path.substring(parent.length + 1);
}

export function childNavigation(route: NavigationChild, parent: PathValues): Route {
  const redirectTo = route.redirectTo ? `/${route.redirectTo}` : undefined;

  if (!route.path.length || route.path.length < parent.length + 1) {
    return { ...route, redirectTo };
  }

  return {
    ...route,
    redirectTo,
    path: getChildPath(route.path, parent),
  };
}

export function withChildNavigation(parent: PathValues): (route: NavigationChild) => Route {
  return (route: NavigationChild) => childNavigation(route, parent);
}
Copy after login

Usage example. In app.routes:

export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('@baf/ui/layout').then((m) => m.LayoutComponent),
    children: [
      {
        path: PATHS.search,
        loadChildren: () => import('./routes/search.routes').then((m) => m.searchRoutes),
      },
    ],
  },
];
Copy after login

In routes/search.routes.ts:

import { 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),
  },
].map(withChildNavigation(PATHS.search));
Copy after login

Analytics

Service for sending events to google analytics and yandex metrika - metrics:

import { InjectionToken } from '@angular/core';

export interface MetricConfig {
  readonly ids?: string[];
  readonly counter?: number;
  readonly domains: string[];
  readonly paths: string[];
}

export const METRIC_CONFIG = new InjectionToken<MetricConfig>('MetricConfig');
Copy after login

MetricConfig - configuration of Google and Yandex metrics:

  • ids - Google IDS list;
  • counter - yandex metrika counter;
  • domains - list of domains for combining analytics sessions;
  • paths - a set of paths when referrers should be reset.

General service for sending events:

import { inject, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
import { tap } from 'rxjs/operators';

import { GoogleAnalytics } from './google-analytics';
import { YandexMetrika } from './yandex.metrika';

export interface MetricOptions {
  readonly [key: string]: unknown;

  readonly ym?: Record<string, unknown>;
  readonly ga?: Record<string, unknown>;
}

export function extractOptions(options?: MetricOptions): { readonly ym?: Record<string, unknown>; readonly ga?: Record<string, unknown> } {
  let { ym, ga } = options ?? {};

  if (options) {
    if (!ym && !ga) {
      ym = options;
      ga = options;
    }
  }

  return { ym, ga };
}

@Injectable()
export class MetricService {
  private readonly router = inject(Router);
  private readonly yandexMetrika = inject(YandexMetrika);
  private readonly googleAnalytics = inject(GoogleAnalytics);

  constructor() {
    this.router.events
      .pipe(
        filter((event): event is NavigationEnd => event instanceof NavigationEnd),
        tap((event) => this.navigation(event.urlAfterRedirects)),
        takeUntilDestroyed(),
      )
      .subscribe();
  }

  init(customerId?: string | number) {
    this.set({
      ga: customerId ? { user_id: customerId } : undefined,
      ym: customerId ? { UserID: customerId } : undefined,
    });
  }

  navigation(url: string): void {
    this.googleAnalytics.sendNavigation(url);
    this.yandexMetrika.hit(url);
  }

  send(action: string, options?: MetricOptions): void {
    const params = extractOptions(options);

    this.googleAnalytics.sendEvent(action, params.ga);
    this.yandexMetrika.reachGoal(action, params.ym);
  }

  set(options: MetricOptions): void {
    const params = extractOptions(options);

    if (params.ga) {
      this.googleAnalytics.set(params.ga);
    }
    if (params.ym) {
      this.yandexMetrika.set(params.ym);
    }
  }
}
Copy after login

Service methods:

  • init - initialization of counters;
  • navigation - sending a page transition event;
  • send - registration event;
  • set - passing parameters to analytics.

Implementation of the Google Analytics service:

import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { Title } from '@angular/platform-browser';

import { METRIC_CONFIG } from './metrica.interface';

declare global {
  interface Window {
    readonly gtag?: (...params: unknown[]) => void;
  }
}

@Injectable()
export class GoogleAnalytics {
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
  private readonly document = inject(DOCUMENT);
  private readonly config = inject(METRIC_CONFIG);
  private readonly title = inject(Title);

  readonly gtag: (...params: unknown[]) => void;

  constructor() {
    if (this.isBrowser && typeof this.document.defaultView?.gtag !== 'undefined' && this.config.ids && this.config.ids?.length > 0) {
      this.gtag = this.document.defaultView.gtag;
    } else {
      this.gtag = () => {};
    }
  }

  set(payload: Record<string, unknown>): void {
    this.gtag('set', payload);
  }

  sendEvent(action: string, payload?: Record<string, unknown>): void {
    this.gtag('event', action, {
      ...payload,
      event_category: payload?.['eventCategory'],
      event_label: payload?.['eventLabel'],
      value: payload?.['eventValue'],
    });
  }

  sendNavigation(url: string): void {
    if (
      (this.isBrowser && !this.config.domains.every((domain) => this.document.referrer.indexOf(domain) < 0)) ||
      !this.config.paths.every((path) => this.document.location.pathname.indexOf(path) < 0)
    ) {
      this.set({ page_referrer: this.document.defaultView?.location.origin ?? '' });
    }

    if (this.config.ids) {
      for (const key of this.config.ids) {
        this.gtag('config', key, {
          page_title: this.title.getTitle(),
          page_path: url,
        });
      }
    }
  }
}
Copy after login

Yandex metrics:

import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';

import { METRIC_CONFIG } from './metrica.interface';

declare global {
  interface Window {
    readonly ym?: (...params: unknown[]) => void;
  }
}

@Injectable()
export class YandexMetrika {
  private readonly document = inject(DOCUMENT);
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
  private readonly config = inject(METRIC_CONFIG);

  private readonly counter: (...params: unknown[]) => void;

  constructor() {
    if (this.isBrowser && typeof this.document.defaultView?.ym !== 'undefined' && !!this.config.counter) {
      this.counter = this.document.defaultView.ym;
    } else {
      this.counter = () => {};
    }
  }

  hit(url: string, options?: Record<string, unknown>): void {
    let clearReferrer = false;
    if (
      (this.isBrowser && !this.config.domains.every((domain) => this.document.referrer.indexOf(domain) < 0)) ||
      !this.config.paths.every((path) => this.document.location.pathname.indexOf(path) < 0)
    ) {
      clearReferrer = true;
    }

    const optionsAll: { referer?: string } = { ...options };
    if (clearReferrer) {
      optionsAll.referer = '';
    }
    this.counter(this.config.counter, 'hit', url, optionsAll);
  }

  reachGoal(target: string, options?: Record<string, unknown>): void {
    this.counter(this.config.counter, 'reachGoal', target, options);
  }

  set(params: Record<string, unknown>, options?: Record<string, unknown>): void {
    this.counter(this.config.counter, 'userParams', params, options);
  }
}
Copy after login

Core

Utility for casting request parameters - api/params.ts:

export type HttpParams = Record<string, string | number | boolean | ReadonlyArray<string | number | boolean>>;

export function castParams(all: Record<string, unknown>): HttpParams {
  const params: HttpParams = {};

  for (const [key, value] of Object.entries(all)) {
    if (Array.isArray(value) && value.length > 0) {
      params[key] = value.filter((val) => {
        return typeof val === 'string' || typeof val === 'boolean' || typeof val === 'number';
      });
    } else if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number') {
      params[key] = value;
    }
  }

  return params;
}
Copy after login

Setting currency - currency/currency.ts:

import { DEFAULT_CURRENCY_CODE, Provider } from '@angular/core';

export function provideCurrency(currencyCode: string): Provider {
  return {
    provide: DEFAULT_CURRENCY_CODE,
    useValue: currencyCode,
  };
}
Copy after login

Getting and using environment variables - env/environment.ts:

import type { ApplicationConfig} from '@angular/core';
import { APP_INITIALIZER, makeStateKey, TransferState } from '@angular/core';

export const ENV_KEY = makeStateKey<Environment>('Environment');

export interface Environment {
  readonly aviasalesToken: string;
  readonly hotellookToken: string;
}

export const ENV_DEFAULT: Environment = {
  aviasalesToken: '',
  hotellookToken: '',
};

export function provideEnv() {
  return [
    {
      provide: APP_INITIALIZER,
      useFactory: (transferState: TransferState) => {
        return () => {
          transferState.set<Environment>(ENV_KEY, {
            aviasalesToken: process.env['AVIASALES_TOKEN'] ?? ENV_DEFAULT.aviasalesToken,
            hotellookToken: process.env['HOTELLOOK_TOKEN'] ?? ENV_DEFAULT.hotellookToken,
          });
        };
      },
      deps: [TransferState],
      multi: true,
    },
  ];
}

export const envConfig: ApplicationConfig = {
  providers: [provideEnv()],
};
Copy after login

Form typing - form/form.ts:

import type { FormControl, FormGroup } from '@angular/forms';

export type FormFor<T> = {
  [P in keyof T]: FormControl<T[P]>;
};

export type FormWithSubFor<T> = {
  [P in keyof T]: T[P] extends Record<string, unknown> ? FormGroup<FormFor<T[P]>> : FormControl<T[P]>;
};

export function castQueryParams(queryParams: Record<string, unknown>, props?: string[]): Record<string, unknown> {
  const mapped: Record<string, unknown> = {};

  const keys = props ?? Object.keys(queryParams);

  for (const key of keys) {
    const value = queryParams[key];

    if (typeof value === 'string' && value.length > 0) {
      if (['true', 'false'].includes(value)) {
        mapped[key] = value === 'true';
      } else if (!isNaN(Number(value))) {
        mapped[key] = Number(value);
      } else {
        mapped[key] = value;
      }
    } else if (typeof value === 'boolean') {
      mapped[key] = value;
    } else if (typeof value === 'number' && value > 0) {
      mapped[key] = value;
    }
  }

  return mapped;
}
Copy after login
Copy after login

FormControl change detection directive:

import type { FormControl, FormGroup } from '@angular/forms';

export type FormFor<T> = {
  [P in keyof T]: FormControl<T[P]>;
};

export type FormWithSubFor<T> = {
  [P in keyof T]: T[P] extends Record<string, unknown> ? FormGroup<FormFor<T[P]>> : FormControl<T[P]>;
};

export function castQueryParams(queryParams: Record<string, unknown>, props?: string[]): Record<string, unknown> {
  const mapped: Record<string, unknown> = {};

  const keys = props ?? Object.keys(queryParams);

  for (const key of keys) {
    const value = queryParams[key];

    if (typeof value === 'string' && value.length > 0) {
      if (['true', 'false'].includes(value)) {
        mapped[key] = value === 'true';
      } else if (!isNaN(Number(value))) {
        mapped[key] = Number(value);
      } else {
        mapped[key] = value;
      }
    } else if (typeof value === 'boolean') {
      mapped[key] = value;
    } else if (typeof value === 'number' && value > 0) {
      mapped[key] = value;
    }
  }

  return mapped;
}
Copy after login
Copy after login

hammerjs setup:

import type { EnvironmentProviders, Provider } from '@angular/core';
import { importProvidersFrom, Injectable } from '@angular/core';
import { HAMMER_GESTURE_CONFIG, HammerGestureConfig, HammerModule } from '@angular/platform-browser';

@Injectable()
export class HammerConfig extends HammerGestureConfig {
  override overrides = {
    swipe: { velocity: 0.4, threshold: 20 },
    pinch: { enable: false },
    rotate: { enable: false },
  };
}

export function provideHammer(): (Provider | EnvironmentProviders)[] {
  return [
    importProvidersFrom(HammerModule),
    {
      provide: HAMMER_GESTURE_CONFIG,
      useClass: HammerConfig,
    },
  ];
}
Copy after login

Setting Content-Type for HTTP requests -interceptors/content-type.interceptor.ts:

import type { HttpInterceptorFn } from '@angular/common/http';

export const contentTypeInterceptor: HttpInterceptorFn = (req, next) => {
  if (!req.headers.has('Content-Type') && req.headers.get('enctype') !== 'multipart/form-data') {
    req = req.clone({ headers: req.headers.set('Content-Type', 'application/json') });
  }

  return next(req);
};
Copy after login

Service for adding classes to components and directives - styles/extra-class.service.ts:

import { DestroyRef, ElementRef, inject, Injectable, Renderer2 } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import type { Observable, Subscription } from 'rxjs';
import { tap } from 'rxjs';

type Styles = string | string[] | undefined | null;

export function toClass(value: unknown | undefined | null, prefix = 'is'): Styles {
  return value ? `${prefix}-${value}` : undefined;
}

@Injectable()
export class ExtraClassService {
  private readonly destroyRef = inject(DestroyRef);
  private readonly render = inject(Renderer2);
  private readonly elementRef: ElementRef<HTMLElement> = inject(ElementRef);

  private styles: Record<string, Styles> = {};
  private subscriptions: Record<string, Subscription> = {};

  update(key: string, styles: Styles): void {
    if (this.styles[key]) {
      const lastStyles = this.styles[key];
      if (Array.isArray(lastStyles)) {
        lastStyles.map((style) => this.render.removeClass(this.elementRef.nativeElement, style));
      } else if (lastStyles) {
        this.render.removeClass(this.elementRef.nativeElement, lastStyles);
      }
    }
    if (Array.isArray(styles)) {
      styles.map((style) => this.render.addClass(this.elementRef.nativeElement, style));
    } else if (styles) {
      this.render.addClass(this.elementRef.nativeElement, styles);
    }
    this.styles[key] = styles;
  }

  patch(style: string, active: boolean): void {
    if (active) {
      this.render.addClass(this.elementRef.nativeElement, style);
    } else {
      this.render.removeClass(this.elementRef.nativeElement, style);
    }
  }

  register(key: string, observable: Observable<unknown>, callback: () => void, start = true): void {
    if (this.subscriptions[key]) {
      this.unregister(key);
    }
    if (start) {
      callback();
    }
    this.subscriptions[key] = observable
      .pipe(
        tap(() => callback()),
        takeUntilDestroyed(this.destroyRef),
      )
      .subscribe();
  }

  unregister(key: string): void {
    this.subscriptions[key].unsubscribe();
  }
}
Copy after login

Custom types - types/type.ts:

export type ChangeFn = (value: any) => void;

export type TouchedFn = () => void;

export type DisplayFn = (value: any, index?: number) => string;

export type MaskFn = (value: any) => string;

export type StyleFn = (value?: any) => string | string[];

export type CoerceBoolean = boolean | string | undefined | null;
Copy after login

Utilities for working with dates and deadlines - utils:

export function getDaysBetweenDates(startDate: Date | string, endDate: Date | string): number {
  return Math.round((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86400000);
}
Copy after login

Короткая реализация для uuid:

export function uuidv4(): string {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
    const r = (Math.random() * 16) | 0;

    const v = c == 'x' ? r : (r & 0x3) | 0x8;

    return v.toString(16);
  });
}
Copy after login

И функция для гуманизации:

export function camelCaseToHumanize(str: string): string {
  return str.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
}
Copy after login

В следующей статье будем разрабатывать свой UI KIT.

Ссылки

Все исходники находятся на github, в репозитории - github.com/Fafnur/buy-and-fly

Демо можно посмотреть здесь - buy-and-fly.fafn.ru/

Мои группы: telegram, medium, vk, x.com, linkedin, site

The above is the detailed content of Development of core services and utilities in Angular 18. For more information, please follow other related articles on the PHP Chinese website!

source:dev.to
Statement of this Website
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn
Popular Tutorials
More>
Latest Downloads
More>
Web Effects
Website Source Code
Website Materials
Front End Template
About us Disclaimer Sitemap
php.cn:Public welfare online PHP training,Help PHP learners grow quickly!