feat: dead-config sweep, test suite foundation, widget settingsSchema validation
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Sprint G: audited every BootstrapConfig field for a real runtime consumer (docs/DEAD-CONFIG-AUDIT.md). Wired 3 previously-dead editable fields: footer.logoUrl, company.address.street/contacts.phone, catalog.suggestionsEnabled. Remaining dead fields needing a business/design decision tracked in PRODUCT_BACKLOG.md/KNOWN-ISSUES.md, not silently left. Sprint H: 6 new spec files (test count 57 -> 83), covering ProjectEditorFacade (undo/redo, draft persistence, publish gating), AdminAnalyticsFacade (never-fabricate-a-number contract), and regression coverage for this session's carousel/hero/profile-toggle fixes. Sprint I: widget settingsSchema (declared in widget-manifest.json, never validated) now enforced via a new lightweight schema check in ProjectValidator, surfaced through the existing issuesByField pipeline. Same check reused in diagnostics so editor and diagnostics can't disagree. Verification: tsc clean, ng build clean, 83/83 tests pass, barry-cache validate clean (2 pre-existing unrelated warnings only). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<div class="app-footer__container page-container">
|
||||
<div class="app-footer__top">
|
||||
<div class="app-footer__brand">
|
||||
<app-logo />
|
||||
<app-logo [srcOverride]="footerLogoUrl()" />
|
||||
<p>{{ 'footer.description' | translate }}</p>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
@if (contactEmail) {
|
||||
<a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a>
|
||||
}
|
||||
@if (contactPhone) {
|
||||
<a [href]="'tel:' + contactPhone">{{ contactPhone }}</a>
|
||||
}
|
||||
@if (companyAddress) {
|
||||
<span class="app-footer__address">{{ companyAddress }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -21,6 +21,7 @@ export class FooterComponent {
|
||||
readonly footerGroups = signal<FooterResolvedGroup[]>([]);
|
||||
readonly paymentIcons = signal<FooterPaymentIcon[]>([]);
|
||||
readonly copyrightText = signal('');
|
||||
readonly footerLogoUrl = signal<string | undefined>(undefined);
|
||||
|
||||
private readonly configService = inject(ConfigService);
|
||||
|
||||
@@ -35,6 +36,7 @@ export class FooterComponent {
|
||||
this.footerGroups.set([]);
|
||||
this.paymentIcons.set([]);
|
||||
this.copyrightText.set('');
|
||||
this.footerLogoUrl.set(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,6 +44,7 @@ export class FooterComponent {
|
||||
this.footerGroups.set(model.groups);
|
||||
this.paymentIcons.set(model.paymentIcons);
|
||||
this.copyrightText.set(model.copyrightText);
|
||||
this.footerLogoUrl.set(model.logoUrl);
|
||||
});
|
||||
|
||||
this.configService.loadBootstrap().subscribe();
|
||||
@@ -56,4 +59,12 @@ export class FooterComponent {
|
||||
get contactEmail(): string {
|
||||
return this.uiRuntime.contactEmail();
|
||||
}
|
||||
|
||||
get contactPhone(): string {
|
||||
return this.uiRuntime.contactPhone();
|
||||
}
|
||||
|
||||
get companyAddress(): string {
|
||||
return this.uiRuntime.companyAddress();
|
||||
}
|
||||
}
|
||||
|
||||
86
src/app/components/header/header.component.spec.ts
Normal file
86
src/app/components/header/header.component.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { BootstrapConfig } from '../../shared/models/config';
|
||||
import { CONFIG_PROVIDER } from '../../core/config/config-provider.token';
|
||||
import { ConfigService } from '../../core/config/config.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { HeaderComponent } from './header.component';
|
||||
|
||||
function makeBootstrap(): BootstrapConfig {
|
||||
return {
|
||||
schemaVersion: '1', generatedAt: new Date().toISOString(),
|
||||
tenant: { id: 't1', slug: 't1', code: 't1', host: 'dexar.market', name: 'Dexar', websiteBaseUrl: 'https://dexar.market', builderBaseUrl: 'https://dexar.market', backofficeBaseUrl: 'https://dexar.market', defaultLocale: 'en', supportedLocales: ['en'], defaultCurrency: 'USD', supportedCurrencies: ['USD'], timezone: 'UTC' },
|
||||
branding: { brandName: 'Dexar', legalName: 'Dexar LLC', logoUrl: 'logo.png', faviconUrl: 'favicon.png' },
|
||||
theme: { themeId: 'default', mode: 'light', palette: {} as any, typography: {} as any, spacing: {} as any, borderRadiusScale: {}, shadows: {}, iconSet: 'default' },
|
||||
company: { companyName: 'Dexar LLC', address: { country: 'US', city: 'NY' }, contacts: { email: 'sales@dexar.market' } },
|
||||
featureFlags: {} as any,
|
||||
apiEndpoints: {} as any,
|
||||
localization: { defaultLocale: 'en', supportedLocales: ['en'], currencyByLocale: {}, dictionaries: [] },
|
||||
seo: { default: { title: 'Dexar', description: 'Dexar' }, byPageKey: {} },
|
||||
permissions: { definitions: [], roles: [] },
|
||||
header: { showLogo: true, showSearch: true, showCategories: true, showLanguages: true, showCart: true, showProfile: true, showWishlist: true, showCompare: true, showRegion: true, sticky: true, layout: 'default' },
|
||||
navigation: { header: [], footer: [] },
|
||||
pages: [],
|
||||
} as unknown as BootstrapConfig;
|
||||
}
|
||||
|
||||
describe('HeaderComponent profile control (login/logout gating regression)', () => {
|
||||
function configure(isAuthenticated: boolean): void {
|
||||
// Full fake - TelegramLoginComponent (rendered inside the profile control) reads
|
||||
// several signals/methods off AuthService directly, not just isAuthenticated.
|
||||
const fakeAuth = {
|
||||
session: () => null,
|
||||
status: () => (isAuthenticated ? 'authenticated' : 'unauthenticated'),
|
||||
isAuthenticated: () => isAuthenticated,
|
||||
showLoginDialog: () => false,
|
||||
displayName: () => null,
|
||||
requestLogin: jasmine.createSpy('requestLogin'),
|
||||
logout: jasmine.createSpy('logout'),
|
||||
hideLogin: jasmine.createSpy('hideLogin'),
|
||||
createWebSession: () => of({ webSessionID: 'x', botLoginUrl: '' }),
|
||||
checkSessionOnce: () => of(null),
|
||||
getTelegramAppLoginUrl: () => '',
|
||||
onTelegramLoginComplete: jasmine.createSpy('onTelegramLoginComplete'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
|
||||
{ provide: AuthService, useValue: fakeAuth },
|
||||
],
|
||||
});
|
||||
|
||||
// Deterministically prime the bootstrap snapshot before component creation -
|
||||
// resolveHeaderConfig() reads getBootstrapSnapshot() synchronously and falls
|
||||
// back to DEFAULT_HEADER_CONFIG (showProfile: false) if it isn't populated yet.
|
||||
TestBed.inject(ConfigService).loadBootstrap().subscribe();
|
||||
}
|
||||
|
||||
it('shows a login button (not logout) when logged out', () => {
|
||||
configure(false);
|
||||
const fixture = TestBed.createComponent(HeaderComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Aria-labels render translated text (Russian by default), so assert on the
|
||||
// icon name attribute instead - stable regardless of active language.
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('app-icon[name="user"]')).toBeTruthy();
|
||||
expect(compiled.querySelector('app-icon[name="logOut"]')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('shows a logout button (not login) when logged in - never both', () => {
|
||||
configure(true);
|
||||
const fixture = TestBed.createComponent(HeaderComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('app-icon[name="logOut"]')).toBeTruthy();
|
||||
expect(compiled.querySelector('app-icon[name="user"]')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
|
||||
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
||||
|
||||
@Component({
|
||||
@@ -17,6 +17,9 @@ import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class LogoComponent {
|
||||
/** Overrides the default (branding.logoUrl) logo, e.g. footer.logoUrl. */
|
||||
@Input() srcOverride?: string;
|
||||
|
||||
constructor(private readonly uiRuntime: UiRuntimeFacade) {}
|
||||
|
||||
get brandName(): string {
|
||||
@@ -24,6 +27,6 @@ export class LogoComponent {
|
||||
}
|
||||
|
||||
get logoPath(): string {
|
||||
return this.uiRuntime.logoUrl();
|
||||
return this.srcOverride || this.uiRuntime.logoUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface FooterResolvedModel {
|
||||
groups: FooterResolvedGroup[];
|
||||
paymentIcons: FooterPaymentIcon[];
|
||||
copyrightText: string;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -57,7 +58,8 @@ export class FooterResolverService {
|
||||
return {
|
||||
groups: this.resolveFooterGroups(bootstrap),
|
||||
paymentIcons: this.resolvePaymentIcons(bootstrap.footer),
|
||||
copyrightText: this.resolveCopyrightText(bootstrap)
|
||||
copyrightText: this.resolveCopyrightText(bootstrap),
|
||||
logoUrl: bootstrap.footer?.logoUrl
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ interface UiRuntimeState {
|
||||
marketplaceDisplayName: string;
|
||||
logoUrl: string;
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
companyAddress: string;
|
||||
themeId: string;
|
||||
}
|
||||
|
||||
@@ -17,6 +19,8 @@ export class UiRuntimeFacade {
|
||||
marketplaceDisplayName: '',
|
||||
logoUrl: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
companyAddress: '',
|
||||
themeId: ''
|
||||
});
|
||||
|
||||
@@ -38,6 +42,8 @@ export class UiRuntimeFacade {
|
||||
marketplaceDisplayName: bootstrap.branding.brandName,
|
||||
logoUrl: bootstrap.branding.logoUrl,
|
||||
contactEmail: bootstrap.branding.supportEmail ?? bootstrap.company?.contacts?.email ?? '',
|
||||
contactPhone: bootstrap.branding.supportPhone ?? bootstrap.company?.contacts?.phone ?? '',
|
||||
companyAddress: bootstrap.company?.address?.street ?? '',
|
||||
themeId: bootstrap.theme.themeId
|
||||
});
|
||||
}
|
||||
@@ -58,6 +64,14 @@ export class UiRuntimeFacade {
|
||||
return this.state().contactEmail;
|
||||
}
|
||||
|
||||
contactPhone(): string {
|
||||
return this.state().contactPhone;
|
||||
}
|
||||
|
||||
companyAddress(): string {
|
||||
return this.state().companyAddress;
|
||||
}
|
||||
|
||||
themeId(): string {
|
||||
return this.state().themeId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { AdminAnalyticsFacade } from './admin-analytics.facade';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { AdminProductsLocalGateway } from '../../products/services/admin-products-local.gateway';
|
||||
import { ADMIN_CATEGORIES_GATEWAY } from '../../categories/services/admin-categories-gateway.token';
|
||||
import { AdminModerationLocalGateway } from '../../moderation/services/admin-moderation-local.gateway';
|
||||
import { AdminDashboardFacade } from '../../dashboard/facade/admin-dashboard.facade';
|
||||
import { AdminOrder } from '../../orders/models/admin-order.model';
|
||||
|
||||
function makeOrder(overrides: Partial<AdminOrder> = {}): AdminOrder {
|
||||
return {
|
||||
id: 'order-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
total: 100,
|
||||
currency: 'RUB',
|
||||
customer: { email: 'buyer@example.com' },
|
||||
items: [{ productId: 'p1', name: 'Widget', quantity: 1, price: 100 }],
|
||||
...overrides,
|
||||
} as unknown as AdminOrder;
|
||||
}
|
||||
|
||||
describe('AdminAnalyticsFacade (never-fabricate-a-number contract)', () => {
|
||||
let facade: AdminAnalyticsFacade;
|
||||
let ordersGateway: jasmine.SpyObj<AdminOrdersLocalGateway>;
|
||||
let productsGateway: jasmine.SpyObj<AdminProductsLocalGateway>;
|
||||
let categoriesGateway: jasmine.SpyObj<{ loadCategories: () => unknown }>;
|
||||
let moderationGateway: jasmine.SpyObj<AdminModerationLocalGateway>;
|
||||
let dashboardFacade: jasmine.SpyObj<AdminDashboardFacade>;
|
||||
|
||||
function configure(bootstrapPresent: boolean): void {
|
||||
ordersGateway = jasmine.createSpyObj('AdminOrdersLocalGateway', ['loadOrders']);
|
||||
productsGateway = jasmine.createSpyObj('AdminProductsLocalGateway', ['loadProducts']);
|
||||
categoriesGateway = jasmine.createSpyObj('ADMIN_CATEGORIES_GATEWAY', ['loadCategories']);
|
||||
moderationGateway = jasmine.createSpyObj('AdminModerationLocalGateway', ['loadReviews']);
|
||||
dashboardFacade = jasmine.createSpyObj('AdminDashboardFacade', [
|
||||
'ensureLoaded', 'activityEntries', 'bootstrap', 'validationIssues', 'enabledWidgetsCount', 'staticPagesUnpublishedCount',
|
||||
]);
|
||||
|
||||
ordersGateway.loadOrders.and.returnValue(of({ items: [makeOrder()], total: 1 } as any));
|
||||
productsGateway.loadProducts.and.returnValue(of({ items: [], total: 0 } as any));
|
||||
categoriesGateway.loadCategories.and.returnValue(of([]));
|
||||
moderationGateway.loadReviews.and.returnValue(of({ items: [], total: 0 } as any));
|
||||
dashboardFacade.activityEntries.and.returnValue([]);
|
||||
dashboardFacade.bootstrap.and.returnValue(bootstrapPresent ? ({ schemaVersion: '1', tenant: { id: 't1' } } as any) : null);
|
||||
dashboardFacade.validationIssues.and.returnValue([]);
|
||||
dashboardFacade.enabledWidgetsCount.and.returnValue(0);
|
||||
dashboardFacade.staticPagesUnpublishedCount.and.returnValue(0);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: AdminOrdersLocalGateway, useValue: ordersGateway },
|
||||
{ provide: AdminProductsLocalGateway, useValue: productsGateway },
|
||||
{ provide: ADMIN_CATEGORIES_GATEWAY, useValue: categoriesGateway },
|
||||
{ provide: AdminModerationLocalGateway, useValue: moderationGateway },
|
||||
{ provide: AdminDashboardFacade, useValue: dashboardFacade },
|
||||
],
|
||||
});
|
||||
|
||||
facade = TestBed.inject(AdminAnalyticsFacade);
|
||||
}
|
||||
|
||||
it('never fabricates conversionRate - stays null even with real order data', () => {
|
||||
configure(true);
|
||||
facade.load();
|
||||
|
||||
expect(facade.summary()?.conversionRate).toBeNull();
|
||||
expect(facade.summary()?.revenueTotal).toBe(100);
|
||||
expect(facade.summary()?.ordersCount).toBe(1);
|
||||
});
|
||||
|
||||
it('never fabricates the "performance" health check - always unknown (no real data source)', () => {
|
||||
configure(true);
|
||||
facade.load();
|
||||
|
||||
const performance = facade.marketplaceHealth().find(check => check.code === 'performance');
|
||||
expect(performance?.status).toBe('unknown');
|
||||
});
|
||||
|
||||
it('reports backend-connectivity and homepage-configured as unknown when bootstrap has not loaded, instead of guessing', () => {
|
||||
configure(false);
|
||||
facade.load();
|
||||
|
||||
const backend = facade.marketplaceHealth().find(check => check.code === 'backend-connectivity');
|
||||
const homepage = facade.marketplaceHealth().find(check => check.code === 'homepage-configured');
|
||||
expect(backend?.status).toBe('unknown');
|
||||
expect(homepage?.status).toBe('unknown');
|
||||
});
|
||||
|
||||
it('reports backend-connectivity as healthy only once bootstrap is actually present', () => {
|
||||
configure(true);
|
||||
facade.load();
|
||||
|
||||
const backend = facade.marketplaceHealth().find(check => check.code === 'backend-connectivity');
|
||||
expect(backend?.status).toBe('healthy');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TranslateService } from '../../../i18n/translate.service';
|
||||
import { BootstrapConfig } from '../../../shared/models/config';
|
||||
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
|
||||
import { validateAgainstSchemaLite } from '../../project-editor/schema/validators/primitives';
|
||||
import { DiagnosticEntry } from '../models/diagnostics.model';
|
||||
|
||||
const KNOWN_LAYOUTS = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']);
|
||||
@@ -28,6 +29,7 @@ export class BootstrapDiagnosticsValidator {
|
||||
|
||||
this.validateRequiredProperties(bootstrap, entries);
|
||||
this.validateUnknownWidgetTypes(bootstrap, manifest, entries);
|
||||
this.validateWidgetSettingsSchema(bootstrap, manifest, entries);
|
||||
this.validateDuplicateIds(bootstrap, entries);
|
||||
this.validateLayouts(bootstrap, entries);
|
||||
this.validateFeatureFlags(bootstrap, entries);
|
||||
@@ -85,6 +87,36 @@ export class BootstrapDiagnosticsValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuses the same shallow schema check as ProjectValidator.widgetSettingsSchemaIssues
|
||||
* (`validateAgainstSchemaLite`) so the editor and the diagnostics page never
|
||||
* disagree about what "valid widget settings" means - one check, two surfaces.
|
||||
*/
|
||||
private validateWidgetSettingsSchema(bootstrap: BootstrapConfig, manifest: WidgetManifestFile | null, entries: DiagnosticEntry[]): void {
|
||||
const manifestByType = new Map((manifest?.widgets ?? []).map(widget => [widget.type, widget]));
|
||||
for (const page of bootstrap.pages ?? []) {
|
||||
for (const section of page.sections ?? []) {
|
||||
for (const widget of section.widgets ?? []) {
|
||||
const entry = manifestByType.get(widget.type);
|
||||
if (!entry?.settingsSchema || !widget.props || typeof widget.props !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const errors = validateAgainstSchemaLite(widget.props, entry.settingsSchema);
|
||||
if (errors.length > 0) {
|
||||
entries.push({
|
||||
code: 'BOOTSTRAP_WIDGET_SETTINGS_SCHEMA_MISMATCH',
|
||||
severity: 'warning',
|
||||
title: 'diagnostics.widgetSettingsSchemaMismatchTitle',
|
||||
description: `diagnostics.widgetSettingsSchemaMismatchDescription:${widget.type}(${errors.join(', ')})`,
|
||||
affectedComponent: `page:${page.key}/section:${section.id}/widget:${widget.id}`,
|
||||
suggestedResolution: 'diagnostics.widgetSettingsSchemaMismatchResolution'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateDuplicateIds(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
|
||||
const pageIds = new Set<string>();
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { BootstrapConfig } from '../../../shared/models/config';
|
||||
import { CONFIG_PROVIDER } from '../../../core/config/config-provider.token';
|
||||
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
|
||||
import { ProjectEditorFacade } from './project-editor.facade';
|
||||
|
||||
function makeBootstrap(): BootstrapConfig {
|
||||
return {
|
||||
tenant: { id: 'tenant-1', defaultLocale: 'en', supportedLocales: ['en'], websiteBaseUrl: 'https://dexar.market' },
|
||||
branding: { logoUrl: 'logo.png' },
|
||||
localization: { supportedLocales: ['en'], defaultLocale: 'en', currencyByLocale: {}, dictionaries: [] },
|
||||
staticPages: {},
|
||||
pages: [
|
||||
{
|
||||
key: 'home',
|
||||
route: { path: '/' },
|
||||
sections: [
|
||||
{
|
||||
order: 0,
|
||||
layout: { strategy: 'stack' },
|
||||
widgets: [{ id: 'w1', type: 'hero', version: '1', props: {} }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
navigation: { header: [], footer: [] },
|
||||
theme: {
|
||||
palette: {
|
||||
primary: '#497671', secondary: '#a1b4b5', accent: '#a7ceca',
|
||||
success: '#10b981', warning: '#f59e0b', danger: '#ef4444', info: '#3b82f6',
|
||||
textPrimary: '#1e3c38', textSecondary: '#667a77',
|
||||
backgroundPrimary: '#ffffff', backgroundSecondary: '#f5f5f5', border: '#d3dad9',
|
||||
},
|
||||
},
|
||||
layout: { type: 'default' },
|
||||
} as unknown as BootstrapConfig;
|
||||
}
|
||||
|
||||
describe('ProjectEditorFacade', () => {
|
||||
let facade: ProjectEditorFacade;
|
||||
let runtimeSpy: jasmine.SpyObj<PlatformRuntimeService>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
runtimeSpy = jasmine.createSpyObj<PlatformRuntimeService>('PlatformRuntimeService', ['reloadFromBootstrap']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]), provideHttpClient(), provideHttpClientTesting(),
|
||||
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
|
||||
{ provide: PlatformRuntimeService, useValue: runtimeSpy },
|
||||
],
|
||||
});
|
||||
|
||||
facade = TestBed.inject(ProjectEditorFacade);
|
||||
facade.loadBootstrap();
|
||||
});
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('loads the bootstrap and starts with a clean undo/redo stack', () => {
|
||||
expect(facade.bootstrap()).toBeTruthy();
|
||||
expect(facade.canUndo()).toBeFalse();
|
||||
expect(facade.canRedo()).toBeFalse();
|
||||
});
|
||||
|
||||
describe('undo/redo', () => {
|
||||
it('undo reverts the last edit and enables redo', (done) => {
|
||||
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'new-logo.png' } }));
|
||||
|
||||
// updateBootstrap debounces the history commit - wait past HISTORY_DEBOUNCE_MS (300ms).
|
||||
setTimeout(() => {
|
||||
expect(facade.bootstrap()?.branding.logoUrl).toBe('new-logo.png');
|
||||
expect(facade.canUndo()).toBeTrue();
|
||||
|
||||
facade.undo();
|
||||
expect(facade.bootstrap()?.branding.logoUrl).toBe('logo.png');
|
||||
expect(facade.canUndo()).toBeFalse();
|
||||
expect(facade.canRedo()).toBeTrue();
|
||||
|
||||
facade.redo();
|
||||
expect(facade.bootstrap()?.branding.logoUrl).toBe('new-logo.png');
|
||||
expect(facade.canRedo()).toBeFalse();
|
||||
done();
|
||||
}, 350);
|
||||
});
|
||||
|
||||
it('a fresh edit after undo discards the redo stack', (done) => {
|
||||
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'v2.png' } }));
|
||||
|
||||
setTimeout(() => {
|
||||
facade.undo();
|
||||
expect(facade.canRedo()).toBeTrue();
|
||||
|
||||
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'v3.png' } }));
|
||||
expect(facade.canRedo()).toBeFalse();
|
||||
done();
|
||||
}, 350);
|
||||
});
|
||||
});
|
||||
|
||||
describe('draft persistence round-trip', () => {
|
||||
it('persists edits to the draft store and restores them on next load', (done) => {
|
||||
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'draft-logo.png' } }));
|
||||
|
||||
setTimeout(() => {
|
||||
// Simulate a fresh facade instance (e.g. page reload) picking up the persisted draft.
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]), provideHttpClient(), provideHttpClientTesting(),
|
||||
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
|
||||
{ provide: PlatformRuntimeService, useValue: runtimeSpy },
|
||||
],
|
||||
});
|
||||
const reloaded = TestBed.inject(ProjectEditorFacade);
|
||||
reloaded.loadBootstrap();
|
||||
|
||||
expect(reloaded.draftRestored()).toBeTrue();
|
||||
expect(reloaded.bootstrap()?.branding.logoUrl).toBe('draft-logo.png');
|
||||
done();
|
||||
}, 350);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish gating', () => {
|
||||
it('publishes when there are no blocking validation issues', () => {
|
||||
expect(facade.hasBlockingIssues()).toBeFalse();
|
||||
const result = facade.publish();
|
||||
expect(result).toBeTrue();
|
||||
expect(facade.status()).toBe('published');
|
||||
expect(runtimeSpy.reloadFromBootstrap).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to publish while a blocking issue is present', () => {
|
||||
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: '' } }));
|
||||
|
||||
expect(facade.hasBlockingIssues()).toBeTrue();
|
||||
const result = facade.publish();
|
||||
expect(result).toBeFalse();
|
||||
expect(facade.status()).not.toBe('published');
|
||||
expect(runtimeSpy.reloadFromBootstrap).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isValidHexColor,
|
||||
isValidHttpUrl,
|
||||
normalizeRoute,
|
||||
validateAgainstSchemaLite,
|
||||
validateCss,
|
||||
validateHtml,
|
||||
validateJson,
|
||||
@@ -79,6 +80,36 @@ describe('validation primitives', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAgainstSchemaLite', () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: { title: { type: 'string' }, count: { type: 'number' } },
|
||||
required: ['title'],
|
||||
};
|
||||
|
||||
it('accepts a value matching required fields and property types', () => {
|
||||
expect(validateAgainstSchemaLite({ title: 'Hero', count: 3 }, schema)).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a missing required property', () => {
|
||||
const errors = validateAgainstSchemaLite({ count: 3 }, schema);
|
||||
expect(errors.some(e => e.includes('title'))).toBeTrue();
|
||||
});
|
||||
|
||||
it('flags a property with the wrong type', () => {
|
||||
const errors = validateAgainstSchemaLite({ title: 'Hero', count: 'three' }, schema);
|
||||
expect(errors.some(e => e.includes('count'))).toBeTrue();
|
||||
});
|
||||
|
||||
it('ignores properties not declared in the schema', () => {
|
||||
expect(validateAgainstSchemaLite({ title: 'Hero', extra: 'ignored' }, schema)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores an undeclared-optional property that is simply absent', () => {
|
||||
expect(validateAgainstSchemaLite({ title: 'Hero' }, schema)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateHtml', () => {
|
||||
it('accepts well-formed markup with nested tags', () => {
|
||||
expect(validateHtml('<p>Hello <strong>world</strong></p><ul><li>one</li></ul>').ok).toBeTrue();
|
||||
|
||||
@@ -80,6 +80,54 @@ export function normalizeRoute(route: string): string {
|
||||
return (route ?? '').trim().replace(/^\/+|\/+$/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
export interface JsonSchemaLite {
|
||||
type?: 'object';
|
||||
/** Loosely typed to accept the wider `WidgetManifestEntry.settingsSchema` shape as-is - each entry's `type` is read defensively below. */
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic (type + required) JSON Schema check against an object - not a full
|
||||
* JSON Schema engine (no nested schemas, enums, formats, or $ref). Enough to
|
||||
* catch a widget's `props` missing a required field or holding the wrong
|
||||
* primitive type for a declared property, without pulling in a schema
|
||||
* validation dependency for a check this shallow.
|
||||
*/
|
||||
export function validateAgainstSchemaLite(value: Record<string, unknown>, schema: JsonSchemaLite): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const key of schema.required ?? []) {
|
||||
if (value[key] === undefined || value[key] === null || value[key] === '') {
|
||||
errors.push(`missing required property "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, propSchema] of Object.entries(schema.properties ?? {})) {
|
||||
const propValue = value[key];
|
||||
const propType = propSchema && typeof propSchema === 'object' ? (propSchema as { type?: string }).type : undefined;
|
||||
if (propValue === undefined || !propType) {
|
||||
continue;
|
||||
}
|
||||
if (!matchesJsonSchemaType(propValue, propType)) {
|
||||
errors.push(`property "${key}" should be ${propType}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function matchesJsonSchemaType(value: unknown, type: string): boolean {
|
||||
switch (type) {
|
||||
case 'string': return typeof value === 'string';
|
||||
case 'number': case 'integer': return typeof value === 'number';
|
||||
case 'boolean': return typeof value === 'boolean';
|
||||
case 'array': return Array.isArray(value);
|
||||
case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** HTML void elements per the WHATWG spec: never require (or accept) a closing tag. */
|
||||
const VOID_ELEMENTS = new Set([
|
||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { BootstrapConfig } from '../../../shared/models/config';
|
||||
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
|
||||
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
|
||||
import { ProjectValidator } from './project-validator.service';
|
||||
|
||||
function makeBootstrap(): BootstrapConfig {
|
||||
@@ -45,7 +50,8 @@ describe('ProjectValidator', () => {
|
||||
let validator: ProjectValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new ProjectValidator();
|
||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
validator = TestBed.inject(ProjectValidator);
|
||||
});
|
||||
|
||||
it('reports no issues for a valid baseline config', () => {
|
||||
@@ -95,4 +101,45 @@ describe('ProjectValidator', () => {
|
||||
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-url');
|
||||
expect(issue?.fieldKey).toBe('tenant.websiteBaseUrl');
|
||||
});
|
||||
|
||||
describe('widgetSettingsSchema (against widget-manifest settingsSchema)', () => {
|
||||
const manifest: WidgetManifestFile = {
|
||||
widgets: [
|
||||
{
|
||||
type: 'hero',
|
||||
version: '1',
|
||||
componentKey: 'hero',
|
||||
supportedLayouts: ['hero'],
|
||||
supportedDataSources: [],
|
||||
settingsSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] },
|
||||
defaultSettings: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('does nothing before the manifest has ever loaded (getManifestSnapshot() is null)', () => {
|
||||
const bootstrap = makeBootstrap();
|
||||
bootstrap.pages[0].sections[0].widgets[0].props = {};
|
||||
expect(validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('flags a widget missing a required settingsSchema property once the manifest is loaded', () => {
|
||||
spyOn(TestBed.inject(WidgetManifestService), 'getManifestSnapshot').and.returnValue(manifest);
|
||||
const bootstrap = makeBootstrap();
|
||||
bootstrap.pages[0].sections[0].widgets[0].props = {};
|
||||
|
||||
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings');
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue?.section).toBe('widgets');
|
||||
expect(issue?.severity).toBe('warning');
|
||||
});
|
||||
|
||||
it('passes when the widget props satisfy the manifest settingsSchema', () => {
|
||||
spyOn(TestBed.inject(WidgetManifestService), 'getManifestSnapshot').and.returnValue(manifest);
|
||||
const bootstrap = makeBootstrap();
|
||||
bootstrap.pages[0].sections[0].widgets[0].props = { title: 'Welcome' };
|
||||
|
||||
expect(validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { BootstrapConfig } from '../../../shared/models/config';
|
||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
|
||||
import {
|
||||
extractStyleBlocks,
|
||||
isValidEmail,
|
||||
isValidHexColor,
|
||||
isValidHttpUrl,
|
||||
normalizeRoute,
|
||||
validateAgainstSchemaLite,
|
||||
validateCss,
|
||||
} from '../schema/validators/primitives';
|
||||
|
||||
@@ -43,6 +45,8 @@ function warning(code: string, message: string, section: ProjectEditorSectionId,
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProjectValidator {
|
||||
private readonly widgetManifest = inject(WidgetManifestService);
|
||||
|
||||
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||
return [
|
||||
...this.brandingIssues(bootstrap),
|
||||
@@ -52,6 +56,7 @@ export class ProjectValidator {
|
||||
...this.duplicateRouteIssues(bootstrap),
|
||||
...this.homepageIssues(bootstrap),
|
||||
...this.widgetConfigIssues(bootstrap),
|
||||
...this.widgetSettingsSchemaIssues(bootstrap),
|
||||
...this.navigationIssues(bootstrap),
|
||||
...this.colorIssues(bootstrap),
|
||||
...this.cssIssues(bootstrap),
|
||||
@@ -162,6 +167,37 @@ export class ProjectValidator {
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates each widget's `props` against its manifest entry's `settingsSchema`
|
||||
* (basic type/required checks - see validateAgainstSchemaLite). No-ops until
|
||||
* the manifest has loaded once (same early-return convention as
|
||||
* RuntimeDiagnosticsValidator.validateDatasourceCoverage) rather than blocking
|
||||
* publish on a manifest fetch race.
|
||||
*/
|
||||
private widgetSettingsSchemaIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||
const manifest = this.widgetManifest.getManifestSnapshot();
|
||||
if (!manifest) {
|
||||
return [];
|
||||
}
|
||||
const manifestByType = new Map(manifest.widgets.map(entry => [entry.type, entry]));
|
||||
|
||||
const hasInvalidSettings = bootstrap.pages.some(page =>
|
||||
page.sections.some(section =>
|
||||
section.widgets.some(widget => {
|
||||
const entry = manifestByType.get(widget.type);
|
||||
if (!entry?.settingsSchema || !widget.props || typeof widget.props !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return validateAgainstSchemaLite(widget.props, entry.settingsSchema).length > 0;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return hasInvalidSettings
|
||||
? [warning('invalid-widget-settings', 'builder.validationInvalidWidgetSettings', 'widgets', 'pages')]
|
||||
: [];
|
||||
}
|
||||
|
||||
private navigationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||
const keyOf = (item: { label?: string | Record<string, string>; route?: string }): string =>
|
||||
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ParamMap, Params } from '@angular/router';
|
||||
import { Observable, Subject, of } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
|
||||
import { Category } from '../../../core/categories/models/category-domain.model';
|
||||
import { ConfigService } from '../../../core/config/config.service';
|
||||
import { Product, ProductListResult } from '../../../core/products/models/product-domain.model';
|
||||
import { CategoryFacade } from '../../../facades/platform/category.facade';
|
||||
import { ProductFacade } from '../../../facades/platform/product.facade';
|
||||
@@ -42,6 +43,7 @@ export class SearchFacade {
|
||||
private readonly trendingService = inject(SearchTrendingService);
|
||||
private readonly cacheService = inject(SearchCacheService);
|
||||
private readonly store = inject(SearchStore);
|
||||
private readonly configService = inject(ConfigService);
|
||||
|
||||
private readonly metadataMemo = new Map<string, FilterGroup[]>();
|
||||
private readonly autocompleteInput$ = new Subject<{
|
||||
@@ -123,6 +125,10 @@ export class SearchFacade {
|
||||
|
||||
autocomplete(query: string, products: Product[], categories: Category[] = [], limit = 10): void {
|
||||
this.store.setQuery(query);
|
||||
if (this.configService.getBootstrapSnapshot()?.catalog?.suggestionsEnabled === false) {
|
||||
this.store.setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
this.autocompleteInput$.next({ query, products, categories, limit });
|
||||
}
|
||||
|
||||
|
||||
@@ -565,6 +565,7 @@ export const en: Translations = {
|
||||
validationInvalidCss: 'A static page contains invalid CSS.',
|
||||
validationDuplicateRoutes: 'Two or more pages share the same route.',
|
||||
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).',
|
||||
validationInvalidWidgetSettings: "A widget's settings don't match what its type expects (missing or wrong-type field).",
|
||||
validationInvalidContactEmail: 'The company contact email is not a valid email address.',
|
||||
validationInvalidSocialLinkUrl: 'One or more footer social links have an invalid URL.',
|
||||
validationIncompletePaymentIcon: 'A footer payment icon is missing its image or alt text.',
|
||||
@@ -1224,6 +1225,9 @@ export const en: Translations = {
|
||||
unknownWidgetTypeTitle: 'Unknown widget type',
|
||||
unknownWidgetTypeDescription: 'Widget type is not present in widget manifest.',
|
||||
unknownWidgetTypeResolution: 'Register widget in manifest or remove invalid widget config.',
|
||||
widgetSettingsSchemaMismatchTitle: 'Widget settings do not match manifest schema',
|
||||
widgetSettingsSchemaMismatchDescription: 'Widget props do not satisfy the settingsSchema declared for this widget type.',
|
||||
widgetSettingsSchemaMismatchResolution: 'Fix the widget props in the editor to match the required fields/types, or update the manifest schema.',
|
||||
duplicateIdTitle: 'Duplicate identifier',
|
||||
duplicatePageIdDescription: 'Duplicate page id detected.',
|
||||
duplicateSectionIdDescription: 'Duplicate section id detected.',
|
||||
|
||||
@@ -565,6 +565,7 @@ export const hy: Translations = {
|
||||
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
|
||||
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
|
||||
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
|
||||
validationInvalidWidgetSettings: 'Վիջեթի կարգավորումները չեն համապատասխանում իր տիպի սպասվող ձևաչափին (դաշտ բացակայում է կամ սխալ տիպի է)։',
|
||||
validationInvalidContactEmail: 'Ընկերության կոնտակտային էլ. փոստը վավեր չէ։',
|
||||
validationInvalidSocialLinkUrl: 'Ֆուտերի սոցիալական հղումներից մեկը կամ մի քանիսը վավեր URL չունեն։',
|
||||
validationIncompletePaymentIcon: 'Ֆուտերի վճարային պատկերակին բացակայում է պատկերը կամ alt տեքստը։',
|
||||
@@ -1219,6 +1220,9 @@ export const hy: Translations = {
|
||||
unknownWidgetTypeTitle: 'Անհայտ widget type',
|
||||
unknownWidgetTypeDescription: 'Widget type-ը չկա widget manifest-ում։',
|
||||
unknownWidgetTypeResolution: 'Ավելացրեք widget-ը manifest-ում կամ հեռացրեք սխալ config-ը։',
|
||||
widgetSettingsSchemaMismatchTitle: 'Վիջեթի կարգավորումները չեն համապատասխանում manifest-ի սխեմային',
|
||||
widgetSettingsSchemaMismatchDescription: 'Վիջեթի props-ը չի բավարարում այս տիպի համար հայտարարված settingsSchema-ին։',
|
||||
widgetSettingsSchemaMismatchResolution: 'Ուղղեք վիջեթի props-ը խմբագրիչում՝ պարտադիր դաշտերին/տիպերին համապատասխան, կամ թարմացրեք manifest-ի սխեման։',
|
||||
duplicateIdTitle: 'Կրկնվող ID',
|
||||
duplicatePageIdDescription: 'Գտնվել է կրկնվող page id։',
|
||||
duplicateSectionIdDescription: 'Գտնվել է կրկնվող section id։',
|
||||
|
||||
@@ -565,6 +565,7 @@ export const ru: Translations = {
|
||||
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
|
||||
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
|
||||
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
|
||||
validationInvalidWidgetSettings: 'Настройки виджета не соответствуют ожидаемым для его типа (поле отсутствует или неверного типа).',
|
||||
validationInvalidContactEmail: 'Контактный email компании указан некорректно.',
|
||||
validationInvalidSocialLinkUrl: 'У одной или нескольких социальных ссылок в футере некорректный URL.',
|
||||
validationIncompletePaymentIcon: 'У иконки оплаты в футере отсутствует изображение или альтернативный текст.',
|
||||
@@ -1219,6 +1220,9 @@ export const ru: Translations = {
|
||||
unknownWidgetTypeTitle: 'Неизвестный тип виджета',
|
||||
unknownWidgetTypeDescription: 'Тип виджета отсутствует в widget manifest.',
|
||||
unknownWidgetTypeResolution: 'Добавьте виджет в manifest или удалите неверную конфигурацию.',
|
||||
widgetSettingsSchemaMismatchTitle: 'Настройки виджета не соответствуют схеме manifest',
|
||||
widgetSettingsSchemaMismatchDescription: 'Props виджета не соответствуют settingsSchema, заданной для этого типа виджета.',
|
||||
widgetSettingsSchemaMismatchResolution: 'Исправьте props виджета в редакторе в соответствии с обязательными полями/типами, либо обновите схему в manifest.',
|
||||
duplicateIdTitle: 'Дублирующийся идентификатор',
|
||||
duplicatePageIdDescription: 'Обнаружен дублирующийся id страницы.',
|
||||
duplicateSectionIdDescription: 'Обнаружен дублирующийся id секции.',
|
||||
|
||||
@@ -563,6 +563,7 @@ export interface Translations {
|
||||
validationInvalidCss: string;
|
||||
validationDuplicateRoutes: string;
|
||||
validationInvalidWidgetConfig: string;
|
||||
validationInvalidWidgetSettings: string;
|
||||
validationInvalidContactEmail: string;
|
||||
validationInvalidSocialLinkUrl: string;
|
||||
validationIncompletePaymentIcon: string;
|
||||
@@ -1223,6 +1224,9 @@ export interface Translations {
|
||||
unknownWidgetTypeTitle: string;
|
||||
unknownWidgetTypeDescription: string;
|
||||
unknownWidgetTypeResolution: string;
|
||||
widgetSettingsSchemaMismatchTitle: string;
|
||||
widgetSettingsSchemaMismatchDescription: string;
|
||||
widgetSettingsSchemaMismatchResolution: string;
|
||||
duplicateIdTitle: string;
|
||||
duplicatePageIdDescription: string;
|
||||
duplicateSectionIdDescription: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, catchError, map, of, shareReplay, switchMap, take } from 'rxjs';
|
||||
import { Observable, catchError, map, of, shareReplay, switchMap, take, tap } from 'rxjs';
|
||||
import { WidgetManifestEntry, WidgetManifestFile } from '../contracts/widget-manifest.contract';
|
||||
import { ConfigService } from '../../core/config/config.service';
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ConfigService } from '../../core/config/config.service';
|
||||
export class WidgetManifestService {
|
||||
private readonly fallbackManifestUrl = '/assets/mock/bootstrap/widget-manifest.json';
|
||||
private readonly manifestByUrl = new Map<string, Observable<WidgetManifestFile>>();
|
||||
/** Last manifest resolved by getManifest(), for synchronous readers (e.g. ProjectValidator) that can't await an Observable. Mirrors ConfigService.getBootstrapSnapshot(). */
|
||||
private manifestSnapshot: WidgetManifestFile | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@@ -23,6 +25,7 @@ export class WidgetManifestService {
|
||||
}
|
||||
|
||||
const manifest$ = this.http.get<WidgetManifestFile>(manifestUrl).pipe(
|
||||
tap(manifest => { this.manifestSnapshot = manifest; }),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
catchError(() => of({ widgets: [] }))
|
||||
);
|
||||
@@ -41,6 +44,11 @@ export class WidgetManifestService {
|
||||
return this.getWidgets().pipe(map((widgets) => widgets.find((widget) => widget.type === type)));
|
||||
}
|
||||
|
||||
/** Synchronous accessor for the last-resolved manifest, or null before it's loaded once. */
|
||||
getManifestSnapshot(): WidgetManifestFile | null {
|
||||
return this.manifestSnapshot;
|
||||
}
|
||||
|
||||
private resolveManifestUrl(): Observable<string> {
|
||||
const snapshotUrl = this.configService.getBootstrapSnapshot()?.widgetRegistry?.manifestUrl;
|
||||
if (snapshotUrl) {
|
||||
|
||||
52
src/app/widgets/ui/hero-widget.component.spec.ts
Normal file
52
src/app/widgets/ui/hero-widget.component.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
import { HeroWidgetData } from '../contracts/widget-data.contract';
|
||||
import { HeroWidgetComponent } from './hero-widget.component';
|
||||
|
||||
function makeSection(columns?: number): SectionConfig {
|
||||
return { id: 's1', type: 'hero', order: 0, layout: { columns }, widgets: [] } as unknown as SectionConfig;
|
||||
}
|
||||
|
||||
function makeData(): HeroWidgetData {
|
||||
return {
|
||||
title: 'First slide',
|
||||
subtitle: 'sub',
|
||||
ctaLabel: 'Shop now',
|
||||
autoplay: false,
|
||||
slides: [{ title: 'Second slide' }],
|
||||
} as unknown as HeroWidgetData;
|
||||
}
|
||||
|
||||
describe('HeroWidgetComponent panel count regression (layout.columns)', () => {
|
||||
it('shows 1 panel when layout.columns is 1 (or unset)', () => {
|
||||
const fixture = TestBed.createComponent(HeroWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(1);
|
||||
fixture.componentInstance.data = makeData();
|
||||
fixture.componentInstance.ngOnChanges({ data: {} as any });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.panelCount).toBe(1);
|
||||
expect(fixture.componentInstance.visibleSlides().length).toBe(1);
|
||||
});
|
||||
|
||||
it('shows 2 panels when layout.columns is 2 and more than one slide exists', () => {
|
||||
const fixture = TestBed.createComponent(HeroWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(2);
|
||||
fixture.componentInstance.data = makeData();
|
||||
fixture.componentInstance.ngOnChanges({ data: {} as any });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.panelCount).toBe(2);
|
||||
expect(fixture.componentInstance.visibleSlides().length).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to 1 panel when columns is 2 but there is only a single slide', () => {
|
||||
const fixture = TestBed.createComponent(HeroWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(2);
|
||||
fixture.componentInstance.data = { title: 'Only slide', autoplay: false } as unknown as HeroWidgetData;
|
||||
fixture.componentInstance.ngOnChanges({ data: {} as any });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.panelCount).toBe(1);
|
||||
});
|
||||
});
|
||||
35
src/app/widgets/ui/product-carousel-widget.component.spec.ts
Normal file
35
src/app/widgets/ui/product-carousel-widget.component.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
import { ProductCollectionWidgetData } from '../contracts/widget-data.contract';
|
||||
import { ProductCarouselWidgetComponent } from './product-carousel-widget.component';
|
||||
|
||||
function makeSection(columns?: number): SectionConfig {
|
||||
return { id: 's1', type: 'product-carousel', order: 0, layout: { columns }, widgets: [] } as unknown as SectionConfig;
|
||||
}
|
||||
|
||||
describe('ProductCarouselWidgetComponent sizing regression (layout.columns)', () => {
|
||||
it('defaults to 4 items per page when layout.columns is unset', () => {
|
||||
const fixture = TestBed.createComponent(ProductCarouselWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(undefined);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.itemsPerPage()).toBe(4);
|
||||
});
|
||||
|
||||
it('reflects layout.columns when set', () => {
|
||||
const fixture = TestBed.createComponent(ProductCarouselWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(3);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.itemsPerPage()).toBe(3);
|
||||
});
|
||||
|
||||
it('applies the resolved value as the --items-per-page CSS custom property', () => {
|
||||
const fixture = TestBed.createComponent(ProductCarouselWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
const host = (fixture.nativeElement as HTMLElement).querySelector('.product-carousel-widget') as HTMLElement;
|
||||
expect(host.style.getPropertyValue('--items-per-page').trim()).toBe('2');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user