Sprint 8: add widget manifest and data source engine
This commit is contained in:
213
src/app/widgets/resolvers/data-source-resolver.service.ts
Normal file
213
src/app/widgets/resolvers/data-source-resolver.service.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, of, map, switchMap } from 'rxjs';
|
||||
import { CategoryFacade } from '../../facades/platform/category.facade';
|
||||
import { ProductFacade } from '../../facades/platform/product.facade';
|
||||
import { SectionConfig, WidgetConfig } from '../../shared/models/config';
|
||||
import { WidgetManifestEntry, WidgetDataSourceName } from '../contracts/widget-manifest.contract';
|
||||
import { BannerWidgetData, CategoriesWidgetData, FooterWidgetData, HeroWidgetData, HtmlWidgetData, PartnersWidgetData, ProductCollectionWidgetData } from '../contracts/widget-data.contract';
|
||||
import { WidgetManifestService } from '../registry/widget-manifest.service';
|
||||
import { Category } from '../../core/categories/models/category-domain.model';
|
||||
import { Product } from '../../core/products/models/product-domain.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DataSourceResolverService {
|
||||
private readonly widgetManifest = inject(WidgetManifestService);
|
||||
private readonly categoryFacade = inject(CategoryFacade);
|
||||
private readonly productFacade = inject(ProductFacade);
|
||||
|
||||
resolve(widget: WidgetConfig, section: SectionConfig): Observable<unknown> {
|
||||
return this.widgetManifest.getWidget(widget.type).pipe(
|
||||
switchMap((definition) => this.resolveByDefinition(definition, widget, section))
|
||||
);
|
||||
}
|
||||
|
||||
private resolveByDefinition(definition: WidgetManifestEntry | undefined, widget: WidgetConfig, section: SectionConfig): Observable<unknown> {
|
||||
const settings = this.resolveSettings(definition, widget);
|
||||
const source = this.resolveSource(widget, settings, definition);
|
||||
|
||||
switch (widget.type) {
|
||||
case 'hero':
|
||||
return of(this.toHeroData(section, settings));
|
||||
case 'categories':
|
||||
return this.resolveCategories(section, widget, settings, source);
|
||||
case 'product-collection':
|
||||
case 'product-carousel':
|
||||
return this.resolveProductCollection(section, widget, settings, source);
|
||||
case 'banner':
|
||||
return of(this.toBannerData(section, settings));
|
||||
case 'html':
|
||||
return of(this.toHtmlData(section, settings));
|
||||
case 'partners':
|
||||
return of(this.toPartnersData(section, settings));
|
||||
case 'footer':
|
||||
case 'footer-navigation':
|
||||
return of(this.toFooterData(section, settings));
|
||||
default:
|
||||
return of({ section, settings });
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSettings(definition: WidgetManifestEntry | undefined, widget: WidgetConfig): Record<string, unknown> {
|
||||
return {
|
||||
...(definition?.defaultSettings ?? {}),
|
||||
...(widget.props ?? {})
|
||||
};
|
||||
}
|
||||
|
||||
private resolveSource(widget: WidgetConfig, settings: Record<string, unknown>, definition?: WidgetManifestEntry): WidgetDataSourceName | undefined {
|
||||
const source = settings['source'] ?? widget.props?.['source'] ?? definition?.defaultSettings?.['source'];
|
||||
return typeof source === 'string' ? source as WidgetDataSourceName : undefined;
|
||||
}
|
||||
|
||||
private toHeroData(section: SectionConfig, settings: Record<string, unknown>): HeroWidgetData {
|
||||
return {
|
||||
section,
|
||||
settings,
|
||||
title: String(settings['title'] ?? ''),
|
||||
subtitle: settings['subtitle'] != null ? String(settings['subtitle']) : undefined,
|
||||
ctaLabel: settings['ctaLabel'] != null ? String(settings['ctaLabel']) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
private resolveCategories(section: SectionConfig, widget: WidgetConfig, settings: Record<string, unknown>, source?: WidgetDataSourceName): Observable<CategoriesWidgetData> {
|
||||
const title = String(settings['title'] ?? '');
|
||||
const emptyMessage = settings['emptyMessage'] != null ? String(settings['emptyMessage']) : undefined;
|
||||
|
||||
switch (source) {
|
||||
case 'parent': {
|
||||
const parentId = this.toNumber(settings['parentId']);
|
||||
if (parentId == null) {
|
||||
return of({ section, settings, title, categories: [], emptyMessage });
|
||||
}
|
||||
|
||||
return this.categoryFacade.getChildren(parentId).pipe(
|
||||
map((categories) => ({ section, settings, title, categories, emptyMessage }))
|
||||
);
|
||||
}
|
||||
case 'manual': {
|
||||
const categories = Array.isArray(settings['categories']) ? settings['categories'] as Category[] : [];
|
||||
return of({ section, settings, title, categories, emptyMessage });
|
||||
}
|
||||
case 'future':
|
||||
return of({ section, settings, title, categories: [], emptyMessage });
|
||||
case 'root':
|
||||
default:
|
||||
return this.categoryFacade.getRootCategories().pipe(
|
||||
map((categories) => ({ section, settings, title, categories, emptyMessage }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveProductCollection(section: SectionConfig, widget: WidgetConfig, settings: Record<string, unknown>, source?: WidgetDataSourceName): Observable<ProductCollectionWidgetData> {
|
||||
const title = String(settings['title'] ?? '');
|
||||
const actionLabel = settings['actionLabel'] != null ? String(settings['actionLabel']) : undefined;
|
||||
const emptyMessage = settings['emptyMessage'] != null ? String(settings['emptyMessage']) : undefined;
|
||||
const count = this.toNumber(settings['count']) ?? 8;
|
||||
const categoryId = this.toNumber(settings['categoryId']);
|
||||
|
||||
switch (source) {
|
||||
case 'latest':
|
||||
return this.productFacade.getLatestProducts({ count }).pipe(
|
||||
map((result) => this.toProductCollectionData(section, settings, title, result.items, actionLabel, emptyMessage))
|
||||
);
|
||||
case 'category':
|
||||
if (categoryId == null) {
|
||||
return of(this.toProductCollectionData(section, settings, title, [], actionLabel, emptyMessage));
|
||||
}
|
||||
|
||||
return this.productFacade.getProductsByCategory(categoryId, { count }).pipe(
|
||||
map((result) => this.toProductCollectionData(section, settings, title, result.items, actionLabel, emptyMessage))
|
||||
);
|
||||
case 'related': {
|
||||
const productId = this.toNumber(settings['productId']);
|
||||
if (productId == null) {
|
||||
return of(this.toProductCollectionData(section, settings, title, [], actionLabel, emptyMessage));
|
||||
}
|
||||
|
||||
return this.productFacade.getRelatedProducts({
|
||||
productID: productId,
|
||||
categoryID: categoryId ?? undefined,
|
||||
count
|
||||
}).pipe(
|
||||
map((result) => this.toProductCollectionData(section, settings, title, result.items, actionLabel, emptyMessage))
|
||||
);
|
||||
}
|
||||
case 'manual': {
|
||||
const products = Array.isArray(settings['products']) ? settings['products'] as Product[] : [];
|
||||
return of(this.toProductCollectionData(section, settings, title, products, actionLabel, emptyMessage));
|
||||
}
|
||||
case 'future':
|
||||
return of(this.toProductCollectionData(section, settings, title, [], actionLabel, emptyMessage));
|
||||
case 'featured':
|
||||
default:
|
||||
return this.productFacade.getFeaturedProducts({ count }).pipe(
|
||||
map((result) => this.toProductCollectionData(section, settings, title, result.items, actionLabel, emptyMessage))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private toProductCollectionData(section: SectionConfig, settings: Record<string, unknown>, title: string, products: Product[], actionLabel?: string, emptyMessage?: string): ProductCollectionWidgetData {
|
||||
return {
|
||||
section,
|
||||
settings,
|
||||
title,
|
||||
products,
|
||||
actionLabel,
|
||||
emptyMessage
|
||||
};
|
||||
}
|
||||
|
||||
private toBannerData(section: SectionConfig, settings: Record<string, unknown>): BannerWidgetData {
|
||||
return {
|
||||
section,
|
||||
settings,
|
||||
title: settings['title'] != null ? String(settings['title']) : undefined,
|
||||
subtitle: settings['subtitle'] != null ? String(settings['subtitle']) : undefined,
|
||||
imageUrl: settings['imageUrl'] != null ? String(settings['imageUrl']) : undefined,
|
||||
ctaLabel: settings['ctaLabel'] != null ? String(settings['ctaLabel']) : undefined,
|
||||
ctaHref: settings['ctaHref'] != null ? String(settings['ctaHref']) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
private toHtmlData(section: SectionConfig, settings: Record<string, unknown>): HtmlWidgetData {
|
||||
return {
|
||||
section,
|
||||
settings,
|
||||
html: String(settings['html'] ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
private toPartnersData(section: SectionConfig, settings: Record<string, unknown>): PartnersWidgetData {
|
||||
const logos = Array.isArray(settings['logos']) ? settings['logos'] as PartnersWidgetData['logos'] : [];
|
||||
|
||||
return {
|
||||
section,
|
||||
settings,
|
||||
title: settings['title'] != null ? String(settings['title']) : undefined,
|
||||
logos
|
||||
};
|
||||
}
|
||||
|
||||
private toFooterData(section: SectionConfig, settings: Record<string, unknown>): FooterWidgetData {
|
||||
const links = Array.isArray(settings['links']) ? settings['links'] as FooterWidgetData['links'] : [];
|
||||
|
||||
return {
|
||||
section,
|
||||
settings,
|
||||
links
|
||||
};
|
||||
}
|
||||
|
||||
private toNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user