
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:
Only navigation deserves special attention.
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;
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;
}
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;
}
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);
}
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),
},
],
},
];
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));
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');
MetricConfig - configuration of Google and Yandex metrics:
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);
}
}
}
Service methods:
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,
});
}
}
}
}
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);
}
}
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;
}
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,
};
}
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()],
};
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;
}
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;
}
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,
},
];
}
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);
};
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();
}
}
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;
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);
}
Короткая реализация для 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);
});
}
И функция для гуманизации:
export function camelCaseToHumanize(str: string): string {
return str.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
}
В следующей статье будем разрабатывать свой 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!
mysql engine introduction
what is drivergenius
How to open state file
How to clear the WPS cloud document space when it is full?
How to solve the problem of not being able to create a new folder in Win7
Self-study for beginners in C language with zero foundation
Windows 11 my computer transfer to the desktop tutorial
ASUS laptop cooling