CAtegory component making
This commit is contained in:
20
src/app/core/categories/category-repository.token.ts
Normal file
20
src/app/core/categories/category-repository.token.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service';
|
||||
import { ApiCategoryRepository } from './repositories/api-category.repository';
|
||||
import { CategoryRepository } from './repositories/category.repository';
|
||||
|
||||
export const CATEGORY_REPOSITORY = new InjectionToken<CategoryRepository>('CATEGORY_REPOSITORY', {
|
||||
providedIn: 'root',
|
||||
factory: () => {
|
||||
const strategy = inject(RuntimeProviderStrategyService);
|
||||
const apiRepository = inject(ApiCategoryRepository);
|
||||
|
||||
switch (strategy.getCategoryProviderMode()) {
|
||||
case 'mock':
|
||||
case 'remote-config':
|
||||
case 'api':
|
||||
default:
|
||||
return apiRepository;
|
||||
}
|
||||
}
|
||||
});
|
||||
48
src/app/core/categories/category.service.ts
Normal file
48
src/app/core/categories/category.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, map, shareReplay } from 'rxjs';
|
||||
import { CATEGORY_REPOSITORY } from './category-repository.token';
|
||||
import { CategoryMapper } from './mappers/category.mapper';
|
||||
import { Category } from './models/category-domain.model';
|
||||
import { CategoryTreeUtils } from './utils/category-tree.utils';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CategoryService {
|
||||
private readonly repository = inject(CATEGORY_REPOSITORY);
|
||||
|
||||
private readonly categories$ = this.repository.getCategories().pipe(
|
||||
map(dtos => CategoryMapper.toDomainList(dtos)),
|
||||
shareReplay({ bufferSize: 1, refCount: true })
|
||||
);
|
||||
|
||||
getAllCategories(): Observable<Category[]> {
|
||||
return this.categories$;
|
||||
}
|
||||
|
||||
getCategoryTree(): Observable<Category[]> {
|
||||
return this.categories$.pipe(map(categories => CategoryTreeUtils.toTree(categories)));
|
||||
}
|
||||
|
||||
getRootCategories(): Observable<Category[]> {
|
||||
return this.getCategoryTree();
|
||||
}
|
||||
|
||||
getCategoryById(categoryId: number): Observable<Category | undefined> {
|
||||
return this.getCategoryTree().pipe(map(tree => CategoryTreeUtils.findById(tree, categoryId)));
|
||||
}
|
||||
|
||||
getChildren(categoryId: number): Observable<Category[]> {
|
||||
return this.categories$.pipe(map(categories => CategoryTreeUtils.getChildren(categories, categoryId)));
|
||||
}
|
||||
|
||||
getBreadcrumb(categoryId: number): Observable<Category[]> {
|
||||
return this.categories$.pipe(map(categories => CategoryTreeUtils.getBreadcrumb(categories, categoryId)));
|
||||
}
|
||||
|
||||
getParent(categoryId: number): Observable<Category | undefined> {
|
||||
return this.categories$.pipe(map(categories => CategoryTreeUtils.getParent(categories, categoryId)));
|
||||
}
|
||||
|
||||
isLeaf(category: Category): boolean {
|
||||
return CategoryTreeUtils.isLeaf(category);
|
||||
}
|
||||
}
|
||||
23
src/app/core/categories/dto/category.dto.ts
Normal file
23
src/app/core/categories/dto/category.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface CategoryNameDto {
|
||||
language?: string;
|
||||
value?: string;
|
||||
valuue?: string;
|
||||
}
|
||||
|
||||
export interface CategoryDto {
|
||||
categoryID?: number;
|
||||
parentID?: number;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
priority?: number;
|
||||
visible?: boolean;
|
||||
categoriesCount?: number;
|
||||
itemCount?: number;
|
||||
names?: CategoryNameDto[];
|
||||
|
||||
id?: string | number;
|
||||
categoryId?: string | number;
|
||||
parentId?: string | number;
|
||||
img?: string;
|
||||
subcategories?: CategoryDto[];
|
||||
}
|
||||
94
src/app/core/categories/mappers/category.mapper.ts
Normal file
94
src/app/core/categories/mappers/category.mapper.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { CategoryDto, CategoryNameDto } from '../dto/category.dto';
|
||||
import { Category, CategoryTranslation } from '../models/category-domain.model';
|
||||
|
||||
export class CategoryMapper {
|
||||
static toDomainList(dtos: CategoryDto[]): Category[] {
|
||||
const byId = new Map<number, Category>();
|
||||
|
||||
for (const dto of dtos) {
|
||||
for (const category of this.flattenDto(dto)) {
|
||||
byId.set(category.id, category);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byId.values())
|
||||
.filter(category => category.visible)
|
||||
.sort((a, b) => a.priority - b.priority || a.id - b.id);
|
||||
}
|
||||
|
||||
static toDomain(dto: CategoryDto, fallbackParentId: number | null = null): Category | null {
|
||||
const id = this.toNumber(dto.categoryID ?? dto.id ?? dto.categoryId);
|
||||
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentId = this.toNumber(dto.parentID ?? dto.parentId) ?? fallbackParentId;
|
||||
const translations = this.toTranslations(dto.names ?? []);
|
||||
const fallbackTitle = this.firstTranslatedTitle(translations) || dto.name || '';
|
||||
|
||||
return {
|
||||
id,
|
||||
parentId: parentId && parentId !== 0 ? parentId : null,
|
||||
title: fallbackTitle,
|
||||
icon: dto.icon ?? dto.img,
|
||||
priority: dto.priority ?? 0,
|
||||
visible: dto.visible ?? true,
|
||||
itemCount: dto.itemCount ?? 0,
|
||||
children: [],
|
||||
translations,
|
||||
};
|
||||
}
|
||||
|
||||
private static flattenDto(dto: CategoryDto, fallbackParentId: number | null = null): Category[] {
|
||||
const category = this.toDomain(dto, fallbackParentId);
|
||||
const currentParentId = category?.id ?? fallbackParentId;
|
||||
const nested = (dto.subcategories ?? []).flatMap(child => this.flattenDto(child, currentParentId));
|
||||
|
||||
return category ? [category, ...nested] : nested;
|
||||
}
|
||||
|
||||
private static toTranslations(names: CategoryNameDto[]): Record<string, CategoryTranslation> {
|
||||
return names.reduce<Record<string, CategoryTranslation>>((acc, entry) => {
|
||||
const language = this.normalizeLanguage(entry.language);
|
||||
const title = entry.value ?? entry.valuue ?? '';
|
||||
|
||||
if (language && title) {
|
||||
acc[language] = { title };
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private static normalizeLanguage(language?: string): string {
|
||||
const normalized = language?.toLowerCase();
|
||||
|
||||
if (normalized === 'am') {
|
||||
return 'hy';
|
||||
}
|
||||
|
||||
return normalized ?? '';
|
||||
}
|
||||
|
||||
private static firstTranslatedTitle(translations: Record<string, CategoryTranslation>): string {
|
||||
return translations['ru']?.title
|
||||
?? translations['en']?.title
|
||||
?? translations['hy']?.title
|
||||
?? Object.values(translations)[0]?.title
|
||||
?? '';
|
||||
}
|
||||
|
||||
private static toNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
15
src/app/core/categories/models/category-domain.model.ts
Normal file
15
src/app/core/categories/models/category-domain.model.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface CategoryTranslation {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
parentId: number | null;
|
||||
title: string;
|
||||
icon?: string;
|
||||
priority: number;
|
||||
visible: boolean;
|
||||
itemCount: number;
|
||||
children: Category[];
|
||||
translations: Record<string, CategoryTranslation>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, timer } from 'rxjs';
|
||||
import { retry } from 'rxjs/operators';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { CategoryDto } from '../dto/category.dto';
|
||||
import { CategoryRepository } from './category.repository';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiCategoryRepository implements CategoryRepository {
|
||||
private readonly retryConfig = {
|
||||
count: 2,
|
||||
delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
|
||||
};
|
||||
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
getCategories(): Observable<CategoryDto[]> {
|
||||
return this.http.get<CategoryDto[]>(`${environment.apiUrl}/category`)
|
||||
.pipe(retry(this.retryConfig));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { CategoryDto } from '../dto/category.dto';
|
||||
|
||||
export interface CategoryRepository {
|
||||
getCategories(): Observable<CategoryDto[]>;
|
||||
}
|
||||
76
src/app/core/categories/utils/category-tree.utils.ts
Normal file
76
src/app/core/categories/utils/category-tree.utils.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Category } from '../models/category-domain.model';
|
||||
|
||||
export class CategoryTreeUtils {
|
||||
static toTree(categories: Category[]): Category[] {
|
||||
const byId = new Map<number, Category>();
|
||||
|
||||
for (const category of categories) {
|
||||
byId.set(category.id, { ...category, children: [] });
|
||||
}
|
||||
|
||||
const roots: Category[] = [];
|
||||
|
||||
for (const category of byId.values()) {
|
||||
if (category.parentId == null) {
|
||||
roots.push(category);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parent = byId.get(category.parentId);
|
||||
if (parent) {
|
||||
parent.children.push(category);
|
||||
} else {
|
||||
roots.push(category);
|
||||
}
|
||||
}
|
||||
|
||||
this.sortTree(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
static flattenTree(categories: Category[]): Category[] {
|
||||
return categories.flatMap(category => [category, ...this.flattenTree(category.children)]);
|
||||
}
|
||||
|
||||
static findById(categories: Category[], categoryId: number): Category | undefined {
|
||||
return this.flattenTree(categories).find(category => category.id === categoryId);
|
||||
}
|
||||
|
||||
static getRootCategories(categories: Category[]): Category[] {
|
||||
return this.toTree(categories);
|
||||
}
|
||||
|
||||
static getChildren(categories: Category[], parentId: number): Category[] {
|
||||
const tree = this.toTree(categories);
|
||||
return this.findById(tree, parentId)?.children ?? [];
|
||||
}
|
||||
|
||||
static getParent(categories: Category[], categoryId: number): Category | undefined {
|
||||
const category = categories.find(item => item.id === categoryId);
|
||||
return category?.parentId == null
|
||||
? undefined
|
||||
: categories.find(item => item.id === category.parentId);
|
||||
}
|
||||
|
||||
static getBreadcrumb(categories: Category[], categoryId: number): Category[] {
|
||||
const byId = new Map(categories.map(category => [category.id, category]));
|
||||
const breadcrumb: Category[] = [];
|
||||
let current = byId.get(categoryId);
|
||||
|
||||
while (current) {
|
||||
breadcrumb.unshift(current);
|
||||
current = current.parentId == null ? undefined : byId.get(current.parentId);
|
||||
}
|
||||
|
||||
return breadcrumb;
|
||||
}
|
||||
|
||||
static isLeaf(category: Category): boolean {
|
||||
return category.children.length === 0;
|
||||
}
|
||||
|
||||
private static sortTree(categories: Category[]): void {
|
||||
categories.sort((a, b) => a.priority - b.priority || a.id - b.id);
|
||||
categories.forEach(category => this.sortTree(category.children));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Category, Item } from '../../../models';
|
||||
import { Item } from '../../../models';
|
||||
import { Category } from '../../categories/models/category-domain.model';
|
||||
|
||||
export type Product = Item;
|
||||
export type ProductCategory = Category;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { ApiService } from '../../../services';
|
||||
import { CategoryService } from '../../categories/category.service';
|
||||
import { ProductDataProvider } from './product-data-provider.interface';
|
||||
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../models/product-domain.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiProductDataProvider implements ProductDataProvider {
|
||||
constructor(private readonly apiService: ApiService) {}
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly categoryService: CategoryService
|
||||
) {}
|
||||
|
||||
getProducts(query: ProductListQuery = {}): Observable<ProductListResult> {
|
||||
return this.apiService.searchItems('', query.count, query.skip, this.toSearchOptions(query))
|
||||
@@ -18,7 +22,7 @@ export class ApiProductDataProvider implements ProductDataProvider {
|
||||
}
|
||||
|
||||
getCategories(): Observable<ProductCategory[]> {
|
||||
return this.apiService.getCategories();
|
||||
return this.categoryService.getAllCategories();
|
||||
}
|
||||
|
||||
searchProducts(query: ProductSearchQuery): Observable<ProductListResult> {
|
||||
|
||||
@@ -28,4 +28,12 @@ export class RuntimeProviderStrategyService {
|
||||
|
||||
return 'api';
|
||||
}
|
||||
|
||||
getCategoryProviderMode(): RuntimeProviderMode {
|
||||
if (environment.useMockData) {
|
||||
return 'mock';
|
||||
}
|
||||
|
||||
return 'api';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user