diff --git a/feature-libs/product-configurator/rulebased/components/attribute/composition/configurator-attribute-composition.directive.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/composition/configurator-attribute-composition.directive.spec.ts new file mode 100644 index 00000000000..ed45a36ca24 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/composition/configurator-attribute-composition.directive.spec.ts @@ -0,0 +1,176 @@ +import { Type, ViewContainerRef } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { FeatureConfigService, LoggerService } from '@spartacus/core'; +import { ConfiguratorTestUtils } from '../../../testing/configurator-test-utils'; +import { ConfiguratorAttributeCompositionConfig } from './configurator-attribute-composition.config'; +import { ConfiguratorAttributeCompositionDirective } from './configurator-attribute-composition.directive'; +import createSpy = jasmine.createSpy; + +class TestComponent {} + +class MockViewContainerRef { + clear = createSpy('vcr.clear'); + createComponent = createSpy('vcr.createComponent'); +} + +let productConfiguratorDeltaRenderingEnabled = false; +class MockFeatureConfigService { + isEnabled(name: string): boolean { + if (name === 'productConfiguratorDeltaRendering') { + return productConfiguratorDeltaRenderingEnabled; + } + return false; + } +} + +describe('ConfiguratorAttributeCompositionDirective', () => { + let classUnderTest: ConfiguratorAttributeCompositionDirective; + let viewContainerRef: ViewContainerRef; + let loggerService: LoggerService; + + function init() { + classUnderTest = TestBed.inject( + ConfiguratorAttributeCompositionDirective as Type + ); + viewContainerRef = TestBed.inject( + ViewContainerRef as Type + ); + loggerService = TestBed.inject(LoggerService as Type); + spyOn(loggerService, 'warn').and.callThrough(); + + classUnderTest['context'] = ConfiguratorTestUtils.getAttributeContext(); + productConfiguratorDeltaRenderingEnabled = false; + } + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ConfiguratorAttributeCompositionDirective, + { + provide: ConfiguratorAttributeCompositionConfig, + useValue: { + productConfigurator: { + assignment: { testComponent: TestComponent }, + }, + }, + }, + { + provide: ViewContainerRef, + useClass: MockViewContainerRef, + }, + { + provide: FeatureConfigService, + useClass: MockFeatureConfigService, + }, + ], + }); + }); + + it('should create', () => { + init(); + expect(classUnderTest).toBeDefined(); + }); + + it('should handle missing assignment config', () => { + TestBed.overrideProvider(ConfiguratorAttributeCompositionConfig, { + useValue: { + productConfigurator: { assignment: undefined }, + }, + }); + init(); + expect(classUnderTest['attrComponentAssignment']).toBeDefined(); + }); + + describe('ngOnInit', () => { + beforeEach(() => { + init(); + }); + + it('should render view if performance feature toggle is off', () => { + classUnderTest.ngOnInit(); + expectComponentRendered(1); + }); + + it('should log if performance feature toggle is off but no component found', () => { + classUnderTest['context'].componentKey = 'not.existing'; + classUnderTest.ngOnInit(); + expectComponentNotRendered(true); + }); + + it('should do nothing if performance feature toggle is on', () => { + productConfiguratorDeltaRenderingEnabled = true; + classUnderTest.ngOnInit(); + expectComponentNotRendered(false); + }); + }); + + describe('ngOnChanges', () => { + beforeEach(() => { + init(); + }); + + it('should render view if performance feature toggle is on', () => { + productConfiguratorDeltaRenderingEnabled = true; + classUnderTest.ngOnChanges(); + expectComponentRendered(1); + }); + + it('should render the attribute only once if it did not change', () => { + productConfiguratorDeltaRenderingEnabled = true; + classUnderTest.ngOnChanges(); + // re-create another context with the same attribute + classUnderTest['context'] = ConfiguratorTestUtils.getAttributeContext(); + classUnderTest.ngOnChanges(); + expectComponentRendered(1); + }); + + it('should re-render the attribute if it changed', () => { + productConfiguratorDeltaRenderingEnabled = true; + classUnderTest.ngOnChanges(); + // re-create another context with the different attribute + classUnderTest['context'] = ConfiguratorTestUtils.getAttributeContext(); + classUnderTest['context'].attribute.selectedSingleValue = 'changed'; + classUnderTest.ngOnChanges(); + expectComponentRendered(2); + }); + + it('should re-render the attribute if group changes', () => { + productConfiguratorDeltaRenderingEnabled = true; + classUnderTest.ngOnChanges(); + // re-create another context with the different attribute + classUnderTest['context'] = ConfiguratorTestUtils.getAttributeContext(); + classUnderTest['context'].group.id = 'changed'; + classUnderTest.ngOnChanges(); + expectComponentRendered(2); + }); + + it('should log if performance feature toggle is on but no component found', () => { + productConfiguratorDeltaRenderingEnabled = true; + classUnderTest['context'].componentKey = 'not.existing'; + classUnderTest.ngOnChanges(); + expectComponentNotRendered(true); + }); + + it('should do nothing if performance feature toggle is off', () => { + productConfiguratorDeltaRenderingEnabled = false; + classUnderTest.ngOnChanges(); + expectComponentNotRendered(false); + }); + }); + + function expectComponentRendered(times: number) { + expect(viewContainerRef.clear).toHaveBeenCalledTimes(times); + expect(viewContainerRef.createComponent).toHaveBeenCalledTimes(times); + expect(loggerService.warn).not.toHaveBeenCalled(); + } + + function expectComponentNotRendered(expectLog: boolean) { + expect(viewContainerRef.clear).not.toHaveBeenCalled(); + expect(viewContainerRef.createComponent).not.toHaveBeenCalled(); + if (expectLog) { + expect(loggerService.warn).toHaveBeenCalled(); + } else { + expect(loggerService.warn).not.toHaveBeenCalled(); + } + } +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/composition/configurator-attribute-composition.directive.ts b/feature-libs/product-configurator/rulebased/components/attribute/composition/configurator-attribute-composition.directive.ts index f5a9021a535..d6cd9e78365 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/composition/configurator-attribute-composition.directive.ts +++ b/feature-libs/product-configurator/rulebased/components/attribute/composition/configurator-attribute-composition.directive.ts @@ -10,21 +10,40 @@ import { Injector, Input, isDevMode, + OnChanges, OnInit, ViewContainerRef, } from '@angular/core'; -import { LoggerService } from '@spartacus/core'; -import { ConfiguratorAttributeCompositionConfig } from './configurator-attribute-composition.config'; +import { + FeatureConfigService, + LoggerService, + ObjectComparisonUtils, +} from '@spartacus/core'; +import { + AttributeComponentAssignment, + ConfiguratorAttributeCompositionConfig, +} from './configurator-attribute-composition.config'; import { ConfiguratorAttributeCompositionContext } from './configurator-attribute-composition.model'; +import { Configurator } from '../../../core/model/configurator.model'; @Directive({ selector: '[cxConfiguratorAttributeComponent]', }) -export class ConfiguratorAttributeCompositionDirective implements OnInit { +export class ConfiguratorAttributeCompositionDirective + implements OnInit, OnChanges +{ @Input('cxConfiguratorAttributeComponent') context: ConfiguratorAttributeCompositionContext; + protected lastRenderedAttribute: Configurator.Attribute; + protected lastRenderedGroupId: string; + protected logger = inject(LoggerService); + private featureConfigService = inject(FeatureConfigService); + + protected readonly attrComponentAssignment: AttributeComponentAssignment = + this.configuratorAttributeCompositionConfig.productConfigurator + ?.assignment ?? []; constructor( protected vcr: ViewContainerRef, @@ -32,18 +51,43 @@ export class ConfiguratorAttributeCompositionDirective implements OnInit { ) {} ngOnInit(): void { - const componentKey = this.context.componentKey; + if ( + !this.featureConfigService.isEnabled('productConfiguratorDeltaRendering') + ) { + const key = this.context.componentKey; + this.renderComponent(this.attrComponentAssignment[key], key); + } + } - const composition = - this.configuratorAttributeCompositionConfig.productConfigurator - ?.assignment; - if (composition) { - this.renderComponent(composition[componentKey], componentKey); + /* + * Each time we update the configuration a completely new configuration state is emitted, including new attribute objects, + * regardless of whether an attribute actually changed or not. Hence, we compare the last rendered attribute with the current state + * and only destroy and re-create the attribute component, if there are actual changes to its data. This improves performance significantly. + */ + ngOnChanges(): void { + if ( + this.featureConfigService.isEnabled('productConfiguratorDeltaRendering') + ) { + const attributeChanged = !ObjectComparisonUtils.deepEqualObjects( + this.lastRenderedAttribute, + this.context.attribute + ); + const groupChanged = this.lastRenderedGroupId !== this.context.group.id; + // attribute can occur with same content twice in different groups + // for example this happens for conflicts. An attribute is rendered differently (link from/to conflict) based on + // if it is part of conflict group or of ordinary group + if (attributeChanged || groupChanged) { + const key = this.context.componentKey; + this.renderComponent(this.attrComponentAssignment[key], key); + } } } protected renderComponent(component: any, componentKey: string) { if (component) { + this.lastRenderedAttribute = this.context.attribute; + this.lastRenderedGroupId = this.context.group.id; + this.vcr.clear(); this.vcr.createComponent(component, { injector: this.getComponentInjector(), }); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/composition/configurator-attribute-composition.model.ts b/feature-libs/product-configurator/rulebased/components/attribute/composition/configurator-attribute-composition.model.ts index 9e8d9c5f1f5..2655748f886 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/composition/configurator-attribute-composition.model.ts +++ b/feature-libs/product-configurator/rulebased/components/attribute/composition/configurator-attribute-composition.model.ts @@ -17,4 +17,5 @@ export class ConfiguratorAttributeCompositionContext { language: string; expMode: boolean; isNavigationToGroupEnabled?: boolean; + isPricingAsync?: boolean; } diff --git a/feature-libs/product-configurator/rulebased/components/attribute/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/index.ts index 341ac105ffa..e7e2dd04e18 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/index.ts +++ b/feature-libs/product-configurator/rulebased/components/attribute/index.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from './composition/index'; +export * from './price-change/index'; export * from './footer/index'; export * from './header/index'; export * from './product-card/index'; @@ -22,4 +24,3 @@ export * from './types/read-only/index'; export * from './types/single-selection-bundle-dropdown/index'; export * from './types/single-selection-bundle/index'; export * from './types/single-selection-image/index'; -export * from './composition/index'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/price-change/configurator-attribute-price-change.service.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/price-change/configurator-attribute-price-change.service.spec.ts new file mode 100644 index 00000000000..3e1d11010cc --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/price-change/configurator-attribute-price-change.service.spec.ts @@ -0,0 +1,194 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { EMPTY, Observable, Subject, of, skip } from 'rxjs'; +import { ConfiguratorAttributePriceChangeService } from './configurator-attribute-price-change.service'; + +import { + CommonConfigurator, + ConfiguratorRouterExtractorService, +} from '@spartacus/product-configurator/common'; +import { ConfiguratorCommonsService } from '../../../core/facade/configurator-commons.service'; +import { Configurator } from '../../../core/model/configurator.model'; +import { ConfiguratorTestUtils } from '../../../testing/configurator-test-utils'; + +const mockConfigTemplate: Configurator.Configuration = { + ...ConfiguratorTestUtils.createConfiguration('c123'), + pricingEnabled: true, + priceSupplements: ConfiguratorTestUtils.createListOfAttributeSupplements( + false, + 1, + 0, + 2, + 3 + ), +}; + +class MockConfiguratorRouterExtractorService { + extractRouterData() { + return of({ owner: mockConfigTemplate.owner }); + } +} + +const configSubject = new Subject(); +class MockConfiguratorCommonsService { + getConfiguration( + owner: CommonConfigurator.Owner + ): Observable { + return owner === mockConfigTemplate.owner ? configSubject : EMPTY; + } +} + +describe('ConfiguratorAttributePriceChangeService', () => { + let classUnderTest: ConfiguratorAttributePriceChangeService; + let mockConfig: Configurator.Configuration; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ConfiguratorAttributePriceChangeService, + { + provide: ConfiguratorRouterExtractorService, + useClass: MockConfiguratorRouterExtractorService, + }, + { + provide: ConfiguratorCommonsService, + useClass: MockConfiguratorCommonsService, + }, + ], + }); + + classUnderTest = TestBed.inject( + ConfiguratorAttributePriceChangeService as Type + ); + + mockConfig = structuredClone(mockConfigTemplate); + }); + + it('should create', () => { + expect(classUnderTest).toBeTruthy(); + }); + + describe('getPriceChangedEvents', () => { + it('should emit always true for the initial rendering/call', () => { + let emitCounter = 0; + classUnderTest + .getPriceChangedEvents(undefined) + .subscribe((priceChanged) => { + expect(priceChanged).toBe(true); + emitCounter++; + }); + mockConfig.priceSupplements = undefined; + configSubject.next(mockConfig); + expect(emitCounter).toBe(1); + }); + + // happens when navigating from overview back to config page + it('should detect price changes during initial rendering/call if supplements are present', () => { + let emitCounter = 0; + classUnderTest + .getPriceChangedEvents('group1@attribute_1_1') + .subscribe((priceChanged) => { + expect(priceChanged).toBe(true); + emitCounter++; + }); + configSubject.next(mockConfig); + expect(emitCounter).toBe(1); + expect(classUnderTest['valuePrices']['value_1_1']).toBeDefined(); + expect(classUnderTest['valuePrices']['value_1_2']).toBeDefined(); + expect(classUnderTest['valuePrices']['value_1_3']).toBeDefined(); + }); + + describe('after initial rendering/call', () => { + function simulateFirstCall() { + // simulating initial call without price supplements + const configOnly = structuredClone(mockConfig); + configOnly.priceSupplements = undefined; + configSubject.next(configOnly); + } + + it('should not emit, if config has no price supplements', () => { + classUnderTest + .getPriceChangedEvents(undefined) + .pipe(skip(1)) + .subscribe(() => { + fail('priceChanged observable should not emit!'); + }); + simulateFirstCall(); + mockConfig.priceSupplements = undefined; + configSubject.next(mockConfig); + }); + + it('should not emit, if config has no matching price supplements', () => { + classUnderTest + .getPriceChangedEvents('otherAttrKey') + .pipe(skip(1)) + .subscribe(() => { + fail('priceChanged observable should not emit!'); + }); + simulateFirstCall(); + configSubject.next(mockConfig); + }); + + it('should emit true and store matching value prices, if config has matching price supplements', () => { + let emitCounter = 0; + classUnderTest + .getPriceChangedEvents('group1@attribute_1_1') + .pipe(skip(1)) + .subscribe((priceChanged) => { + expect(priceChanged).toBe(true); + emitCounter++; + }); + simulateFirstCall(); + configSubject.next(mockConfig); + expect(emitCounter).toBe(1); + expect(classUnderTest['valuePrices']['value_1_1']).toBeDefined(); + expect(classUnderTest['valuePrices']['value_1_2']).toBeDefined(); + expect(classUnderTest['valuePrices']['value_1_3']).toBeDefined(); + }); + + it('should not emit again if prices are not changed', () => { + classUnderTest['lastAttributeSupplement'] = + mockConfig.priceSupplements?.[0]; + classUnderTest + .getPriceChangedEvents('group1@attribute_1_1') + .pipe(skip(1)) + .subscribe(() => { + fail('priceChanged observable should not emit!'); + }); + simulateFirstCall(); + configSubject.next(mockConfig); + }); + }); + }); + + describe('mergePriceIntoValue', () => { + it('should create a new object combining value and price if onPriceChanged was called for this value before', () => { + const valuePrice = { value: 100, currencyIso: 'USD' }; + classUnderTest['storeValuePrice']('valueKey', valuePrice); + expect( + classUnderTest['mergePriceIntoValue']({ + valueCode: '1223', + name: 'valueKey', + }) + ).toEqual({ + valueCode: '1223', + name: 'valueKey', + valuePrice: valuePrice, + }); + }); + + it('should return just the value if onPriceChanged was NOT called for this value before', () => { + const valuePrice = { value: 100, currencyIso: 'USD' }; + classUnderTest['storeValuePrice']('anotherValueKey', valuePrice); + expect( + classUnderTest['mergePriceIntoValue']({ + valueCode: '1223', + name: 'valueKey', + }) + ).toEqual({ + valueCode: '1223', + name: 'valueKey', + }); + }); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/price-change/configurator-attribute-price-change.service.ts b/feature-libs/product-configurator/rulebased/components/attribute/price-change/configurator-attribute-price-change.service.ts new file mode 100644 index 00000000000..234398e9497 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/price-change/configurator-attribute-price-change.service.ts @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { inject, Injectable } from '@angular/core'; +import { ObjectComparisonUtils } from '@spartacus/core'; +import { ConfiguratorRouterExtractorService } from '@spartacus/product-configurator/common'; +import { + EMPTY, + filter, + Observable, + of, + shareReplay, + switchMap, + tap, +} from 'rxjs'; +import { ConfiguratorCommonsService } from '../../../core/facade/configurator-commons.service'; +import { Configurator } from '../../../core/model/configurator.model'; + +/** + * Stateful service to react on price changes of the configuration. Hence components using this service must provide it within their declaration: + * + * @Component({ providers: [ConfiguratorAttributePriceChangeService] }) + * + * getPriceChangedEvents will return an observable that emits whenever the price of a monitored attribute (identified by the provided attribute key) changes, + * hence allowing the subscriber for example to trigger rerendering of the prices on the UI. + */ +@Injectable() +export class ConfiguratorAttributePriceChangeService { + protected configuratorRouterExtractorService = inject( + ConfiguratorRouterExtractorService + ); + protected configuratorCommonsService = inject(ConfiguratorCommonsService); + protected lastAttributeSupplement: + | Configurator.AttributeSupplement + | undefined; + protected valuePrices: { [valueName: string]: Configurator.PriceDetails } = + {}; + + /** + * Returns an observable that shall be used by all components supporting delta rendering mode. + * It will monitor the price supplements of configuration observable and emit true if price supplements + * matching the given attribute key have changed. + * Additionally it returns always true for the first emission of the underlying configuration observable. + * This ensures that an enclosing UI component will initially render, even if the async pricing request ist still running, + * so that the UI is not blocked. Afterwards a rerender shall only occur if prices change. + * This all assumes that the enclosing UI component itself gets recreated or rerendered (triggered elsewhere) whenever the attribute itself changes content wise. + * + * @param attributeKey key of the attribute for which the prices should be checked for changes + * @returns observable that emits 'true' each time a price changes and hence there is the need to rerender the enclosing component + */ + getPriceChangedEvents(attributeKey: string | undefined): Observable { + let isInitialConfiguration: boolean = true; + return this.configuratorRouterExtractorService.extractRouterData().pipe( + switchMap((routerData) => { + return this.configuratorCommonsService + .getConfiguration(routerData.owner) + .pipe( + // Initially render attribute without prices, so UI is not blocked, otherwise only re-ender if prices changed. + // Changes of attribute itself are already handled in the attribute composition directive. + filter( + (config) => isInitialConfiguration || !!config.priceSupplements + ), + switchMap((config) => { + if (!config.priceSupplements) { + return of(true); + } + const pricesChanged = this.checkForValuePriceChanges( + config, + attributeKey + ); + return pricesChanged ? of(true) : EMPTY; + }), + tap(() => (isInitialConfiguration = false)) + ); + }), + shareReplay({ bufferSize: 1, refCount: true }) + ); + } + + /** + * Merges the stored value price data into the given value, if available. + * As the value might be read-only a new object will be returned combining price and value. + * + * @param value the value + * @returns the new value with price + */ + mergePriceIntoValue(value: Configurator.Value): Configurator.Value { + const valueName = value.name; + if (valueName && this.valuePrices[valueName]) { + value = { ...value, valuePrice: this.valuePrices[valueName] }; + } + return value; + } + + /** + * Extracts the relevant value prices from the price supplements + * and stores them within the component. Returns a boolean indicating + * whether there were any value price changes. + * + * @param config current config + * @param attributeKey key of the attribute for which the prices should be checked for changes + * @returns {true}, only if at least one value price changed + */ + protected checkForValuePriceChanges( + config: Configurator.Configuration, + attributeKey: string | undefined + ): boolean { + const attributeSupplement = config.priceSupplements?.find( + (supplement) => supplement.attributeUiKey === attributeKey + ); + const changed = !ObjectComparisonUtils.deepEqualObjects( + this.lastAttributeSupplement ?? {}, + attributeSupplement ?? {} + ); + if (changed) { + this.lastAttributeSupplement = attributeSupplement; + attributeSupplement?.valueSupplements.forEach((valueSupplement) => + this.storeValuePrice( + valueSupplement.attributeValueKey, + valueSupplement.priceValue + ) + ); + } + return changed; + } + + protected storeValuePrice( + valueName: string, + valuePrice: Configurator.PriceDetails + ) { + this.valuePrices[valueName] = valuePrice; + } +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/price-change/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/price-change/index.ts new file mode 100644 index 00000000000..1d889a8e70a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/price-change/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './configurator-attribute-price-change.service'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.spec.ts index 20d2a67b48e..cc9e48f3f27 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.spec.ts +++ b/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.spec.ts @@ -25,6 +25,7 @@ import { ConfiguratorPriceComponentOptions } from '../../price/configurator-pric import { ConfiguratorShowMoreComponent } from '../../show-more/configurator-show-more.component'; import { ConfiguratorAttributeQuantityComponentOptions } from '../quantity/configurator-attribute-quantity.component'; import { ConfiguratorAttributeProductCardComponent } from './configurator-attribute-product-card.component'; +import { ConfiguratorStorefrontUtilsService } from '../../service/configurator-storefront-utils.service'; const product: Product = { name: 'Product Name', @@ -173,6 +174,10 @@ describe('ConfiguratorAttributeProductCardComponent', () => { provide: ProductService, useClass: MockProductService, }, + { + provide: ConfiguratorStorefrontUtilsService, + useValue: {}, + }, ], }) .overrideComponent(ConfiguratorAttributeProductCardComponent, { diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.spec.ts index d7d4c705d3d..ad318a11423 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.spec.ts +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.spec.ts @@ -1,9 +1,13 @@ import { Type } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { ConfiguratorUISettingsConfig } from '@spartacus/product-configurator/rulebased'; +import { FeatureConfigService, I18nTestingModule } from '@spartacus/core'; +import { of } from 'rxjs'; import { Configurator } from '../../../../core/model/configurator.model'; import { ConfiguratorTestUtils } from '../../../../testing/configurator-test-utils'; +import { ConfiguratorUISettingsConfig } from '../../../config/configurator-ui-settings.config'; +import { ConfiguratorAttributePriceChangeService } from '../../price-change/configurator-attribute-price-change.service'; import { ConfiguratorAttributeBaseComponent } from './configurator-attribute-base.component'; +import { ConfiguratorStorefrontUtilsService } from '../../../service/configurator-storefront-utils.service'; const attributeCode = 1; const currentAttribute: Configurator.Attribute = { @@ -21,23 +25,60 @@ let configuratorUISettingsConfig: ConfiguratorUISettingsConfig = { }, }; +class MockConfiguratorDeltaRenderingService { + getPriceChangedEvents() { + return of(false); + } + mergePriceIntoValue(value: Configurator.Value) { + return value; + } +} + +let productConfiguratorDeltaRenderingEnabled = false; +class MockFeatureConfigService { + isEnabled(name: string): boolean { + if (name === 'productConfiguratorDeltaRendering') { + return productConfiguratorDeltaRenderingEnabled; + } + return false; + } +} + describe('ConfiguratorAttributeBaseComponent', () => { let classUnderTest: ConfiguratorAttributeBaseComponent; + let configuratorDeltaRenderingService: ConfiguratorAttributePriceChangeService; beforeEach(() => { TestBed.configureTestingModule({ + imports: [I18nTestingModule], providers: [ ConfiguratorAttributeBaseComponent, { provide: ConfiguratorUISettingsConfig, useValue: configuratorUISettingsConfig, }, + { + provide: ConfiguratorAttributePriceChangeService, + useClass: MockConfiguratorDeltaRenderingService, + }, + { + provide: ConfiguratorStorefrontUtilsService, + useValue: {}, + }, + { provide: FeatureConfigService, useClass: MockFeatureConfigService }, ], }); classUnderTest = TestBed.inject( ConfiguratorAttributeBaseComponent as Type ); + configuratorDeltaRenderingService = TestBed.inject( + ConfiguratorAttributePriceChangeService as Type + ); + spyOn( + configuratorDeltaRenderingService, + 'getPriceChangedEvents' + ).and.callThrough(); }); it('should generate value key', () => { @@ -483,6 +524,88 @@ describe('ConfiguratorAttributeBaseComponent', () => { }); }); + describe('$priceChanged', () => { + it('should emit true immediately, if delta rendering is not initialized', () => { + let emitted = false; + classUnderTest.priceChangedEvent$ + .subscribe((priceChanged) => { + expect(priceChanged).toBe(true); + emitted = true; + }) + .unsubscribe(); + expect(emitted).toBe(true); + expect( + configuratorDeltaRenderingService.getPriceChangedEvents + ).not.toHaveBeenCalled(); + }); + + it('should emit true immediately, if price changed event is initialized in synchronous pricing mode', () => { + productConfiguratorDeltaRenderingEnabled = true; + classUnderTest['initPriceChangedEvent'](false, 'attrKey'); + let emitted = false; + classUnderTest.priceChangedEvent$ + .subscribe((priceChanged) => { + expect(priceChanged).toBe(true); + emitted = true; + }) + .unsubscribe(); + expect(emitted).toBe(true); + expect( + configuratorDeltaRenderingService.getPriceChangedEvents + ).not.toHaveBeenCalled(); + }); + + it('should emit true immediately, price changed event is initialized but no price change service injected', () => { + productConfiguratorDeltaRenderingEnabled = true; + classUnderTest['configuratorAttributePriceChangeService'] = null; + classUnderTest['initPriceChangedEvent'](true, 'attrKey'); + let emitted = false; + classUnderTest.priceChangedEvent$ + .subscribe((priceChanged) => { + expect(priceChanged).toBe(true); + emitted = true; + }) + .unsubscribe(); + expect(emitted).toBe(true); + expect( + configuratorDeltaRenderingService.getPriceChangedEvents + ).not.toHaveBeenCalled(); + }); + + it('should emit true immediately, if price changed event is initialized but delta rendering feature flag is deactivated', () => { + productConfiguratorDeltaRenderingEnabled = false; + classUnderTest['initPriceChangedEvent'](true, 'attrKey'); + let emitted = false; + classUnderTest.priceChangedEvent$ + .subscribe((priceChanged) => { + expect(priceChanged).toBe(true); + emitted = true; + }) + .unsubscribe(); + expect(emitted).toBe(true); + expect( + configuratorDeltaRenderingService.getPriceChangedEvents + ).not.toHaveBeenCalled(); + }); + + it('should emit false immediately, if price changed event is initialized proper', () => { + productConfiguratorDeltaRenderingEnabled = true; + classUnderTest['initPriceChangedEvent'](true, 'attrKey'); + let emitted = false; + productConfiguratorDeltaRenderingEnabled = true; + classUnderTest.priceChangedEvent$ + .subscribe((priceChanged) => { + expect(priceChanged).toBe(false); + emitted = true; + }) + .unsubscribe(); + expect(emitted).toBe(true); + expect( + configuratorDeltaRenderingService.getPriceChangedEvents + ).toHaveBeenCalled(); + }); + }); + describe('isNoValueSelected', () => { it('should return `false` in case there are no values', () => { expect(classUnderTest['isNoValueSelected'](currentAttribute)).toBe(true); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.ts index 015ed40f77a..09a4146f229 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.ts +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.ts @@ -5,8 +5,13 @@ */ import { inject } from '@angular/core'; +import { FeatureConfigService, TranslationService } from '@spartacus/core'; +import { Observable, of, take } from 'rxjs'; import { Configurator } from '../../../../core/model/configurator.model'; import { ConfiguratorUISettingsConfig } from '../../../config/configurator-ui-settings.config'; +import { ConfiguratorPriceComponentOptions } from '../../../price/configurator-price.component'; +import { ConfiguratorAttributePriceChangeService } from '../../price-change/configurator-attribute-price-change.service'; +import { ConfiguratorStorefrontUtilsService } from '../../../service/configurator-storefront-utils.service'; /** * Service to provide unique keys for elements on the UI and for sending to configurator @@ -14,6 +19,24 @@ import { ConfiguratorUISettingsConfig } from '../../../config/configurator-ui-se export class ConfiguratorAttributeBaseComponent { protected configuratorUISettingsConfig = inject(ConfiguratorUISettingsConfig); + protected translation = inject(TranslationService); + + /** + * as the service is stateful any using component shall provide it within the components declaration: + * + * @Component({ providers: [ConfiguratorAttributePriceChangeService] }) + * + * otherwise the service will be null. Hence the service is marked as optional here. + */ + protected configuratorAttributePriceChangeService = inject( + ConfiguratorAttributePriceChangeService, + { optional: true } + ); + protected configuratorStorefrontUtilsService = inject( + ConfiguratorStorefrontUtilsService + ); + + private _featureConfigService = inject(FeatureConfigService); private static SEPERATOR = '--'; private static PREFIX = 'cx-configurator'; @@ -22,6 +45,25 @@ export class ConfiguratorAttributeBaseComponent { private static PREFIX_DDLB_OPTION_PRICE_VALUE = 'option--price'; protected static MAX_IMAGE_LABEL_CHARACTERS = 16; + listenForPriceChanges: boolean; + priceChangedEvent$: Observable = of(true); // no delta rendering - always render directly only once + protected initPriceChangedEvent( + isPricingAsync = false, + attributeKey?: string + ) { + if ( + isPricingAsync && + this.configuratorAttributePriceChangeService && + this._featureConfigService.isEnabled('productConfiguratorDeltaRendering') + ) { + this.listenForPriceChanges = true; + this.priceChangedEvent$ = + this.configuratorAttributePriceChangeService.getPriceChangedEvents( + attributeKey + ); + } + } + /** * Creates unique key for config value on the UI * @param prefix for key depending on usage (e.g. uiType, label) @@ -251,6 +293,10 @@ export class ConfiguratorAttributeBaseComponent { } protected getValuePrice(value: Configurator.Value | undefined): string { + if (value && this.configuratorAttributePriceChangeService) { + value = + this.configuratorAttributePriceChangeService.mergePriceIntoValue(value); + } if (value?.valuePrice?.value && !value.selected) { if (value.valuePrice.value < 0) { return ` [${value.valuePrice?.formattedValue}]`; @@ -358,4 +404,89 @@ export class ConfiguratorAttributeBaseComponent { !this.isReadOnly(attribute) ); } + + /** + * Creates a text describing the current attribute that can be used as ARIA label. + * Includes price information. If a total price is available this price will be used, + * otherwise it falls back to the value price, or if no price is available, + * no price information will be included in the text. + * + * @param attribute the attribute + * @param value the value + * @param considerSelectionState = false + - optional, depending on the underlying UI control the screen + * might announce the selection state on its own, so it is not always desired to include it here. + * @returns translated text + */ + protected getAriaLabelGeneric( + attribute: Configurator.Attribute, + value: Configurator.Value, + considerSelectionState = false + ): string { + value = + this.configuratorAttributePriceChangeService?.mergePriceIntoValue( + value + ) ?? value; + const params: { value?: string; attribute?: string; price?: string } = { + value: value.valueDisplay, + attribute: attribute.label, + }; + + const includedSelected = considerSelectionState && value.selected; + let key = includedSelected + ? 'configurator.a11y.selectedValueOfAttributeFullWithPrice' + : this.getAriaLabelForValueWithPrice(this.isReadOnly(attribute)); + if (value.valuePriceTotal && value.valuePriceTotal?.value !== 0) { + params.price = value.valuePriceTotal.formattedValue; + } else if (value.valuePrice && value.valuePrice?.value !== 0) { + params.price = value.valuePrice.formattedValue; + } else { + key = includedSelected + ? 'configurator.a11y.selectedValueOfAttributeFull' + : this.getAriaLabelForValue(this.isReadOnly(attribute)); + } + + let ariaLabel = ''; + this.translation + .translate(key, params) + .pipe(take(1)) + .subscribe((text) => (ariaLabel = text)); + + return ariaLabel; + } + + /** + * Extract corresponding value price formula parameters. + * For all non-single selection types types the complete price formula should be displayed at the value level. + * + * @param value - Configurator value + * @return new price formula + */ + extractValuePriceFormulaParameters( + value: Configurator.Value + ): ConfiguratorPriceComponentOptions { + value = + this.configuratorAttributePriceChangeService?.mergePriceIntoValue( + value + ) ?? value; + return { + quantity: value.quantity, + price: value.valuePrice, + priceTotal: value.valuePriceTotal, + isLightedUp: value.selected, + }; + } + + /** + * Checks if the value is the last selected value. + * + * @param valueCode code of the value + * @returns true, only if this value is the last selected value + */ + isLastSelected(attributeName: string, valueCode: string): boolean { + return this.configuratorStorefrontUtilsService.isLastSelected( + attributeName, + valueCode + ); + } } diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-multi-selection-base.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-multi-selection-base.component.spec.ts index d5de90553a0..384c1c9deb3 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-multi-selection-base.component.spec.ts +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-multi-selection-base.component.spec.ts @@ -7,6 +7,8 @@ import { ConfiguratorAttributeQuantityService } from '../../quantity/configurato import { ConfiguratorAttributeMultiSelectionBaseComponent } from './configurator-attribute-multi-selection-base.component'; import { ConfiguratorTestUtils } from '../../../../testing/configurator-test-utils'; import { ConfiguratorCommonsService } from '../../../../core/facade/configurator-commons.service'; +import { I18nTestingModule } from '@spartacus/core'; +import { ConfiguratorStorefrontUtilsService } from '../../../service/configurator-storefront-utils.service'; const createTestValue = ( price: number | undefined, @@ -54,6 +56,7 @@ describe('ConfiguratorAttributeMultiSelectionBaseComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [ExampleConfiguratorAttributeMultiSelectionComponent], + imports: [I18nTestingModule], providers: [ ConfiguratorAttributeQuantityService, { @@ -64,6 +67,10 @@ describe('ConfiguratorAttributeMultiSelectionBaseComponent', () => { provide: ConfiguratorCommonsService, useClass: MockConfiguratorCommonsService, }, + { + provide: ConfiguratorStorefrontUtilsService, + useValue: {}, + }, ], }).compileComponents(); })); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-multi-selection-base.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-multi-selection-base.component.ts index 103ca0dd5ea..8bf1731ee6c 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-multi-selection-base.component.ts +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-multi-selection-base.component.ts @@ -10,11 +10,11 @@ import { map } from 'rxjs/operators'; import { Configurator } from '../../../../core/model/configurator.model'; import { ConfiguratorAttributeCompositionContext } from '../../composition/configurator-attribute-composition.model'; +import { ConfiguratorCommonsService } from '../../../../core/facade/configurator-commons.service'; import { ConfiguratorPriceComponentOptions } from '../../../price/configurator-price.component'; import { ConfiguratorAttributeQuantityComponentOptions } from '../../quantity/configurator-attribute-quantity.component'; import { ConfiguratorAttributeQuantityService } from '../../quantity/configurator-attribute-quantity.service'; import { ConfiguratorAttributeBaseComponent } from './configurator-attribute-base.component'; -import { ConfiguratorCommonsService } from '../../../../core/facade/configurator-commons.service'; @Directive() // eslint-disable-next-line @angular-eslint/directive-class-suffix @@ -34,6 +34,10 @@ export abstract class ConfiguratorAttributeMultiSelectionBaseComponent extends C this.attribute = attributeComponentContext.attribute; this.ownerKey = attributeComponentContext.owner.key; this.expMode = attributeComponentContext.expMode; + this.initPriceChangedEvent( + attributeComponentContext.isPricingAsync, + attributeComponentContext.attribute.key + ); } /** @@ -124,22 +128,4 @@ export abstract class ConfiguratorAttributeMultiSelectionBaseComponent extends C isLightedUp: true, }; } - - /** - * Extract corresponding value price formula parameters. - * For the multi-selection attribute types the complete price formula should be displayed at the value level. - * - * @param {Configurator.Value} value - Configurator value - * @return {ConfiguratorPriceComponentOptions} - New price formula - */ - extractValuePriceFormulaParameters( - value: Configurator.Value - ): ConfiguratorPriceComponentOptions { - return { - quantity: value.quantity, - price: value.valuePrice, - priceTotal: value.valuePriceTotal, - isLightedUp: value.selected, - }; - } } diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-single-selection-base.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-single-selection-base.component.spec.ts index 19b7c235d30..dae970ce6e8 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-single-selection-base.component.spec.ts +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-single-selection-base.component.spec.ts @@ -1,21 +1,37 @@ import { Component } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { UntypedFormControl } from '@angular/forms'; +import { StoreModule } from '@ngrx/store'; import { I18nTestingModule, TranslationService } from '@spartacus/core'; import { BehaviorSubject } from 'rxjs'; -import { Configurator } from '../../../../core/model/configurator.model'; -import { ConfiguratorAttributeCompositionContext } from '../../composition/configurator-attribute-composition.model'; -import { ConfigFormUpdateEvent } from '../../../form'; -import { ConfiguratorAttributeQuantityService } from '../../quantity/configurator-attribute-quantity.service'; -import { ConfiguratorAttributeSingleSelectionBaseComponent } from './configurator-attribute-single-selection-base.component'; import { ConfiguratorCommonsService } from '../../../../core/facade/configurator-commons.service'; -import { ConfiguratorTestUtils } from '../../../../testing/configurator-test-utils'; -import { StoreModule } from '@ngrx/store'; +import { Configurator } from '../../../../core/model/configurator.model'; import { CONFIGURATOR_FEATURE } from '../../../../core/state/configurator-state'; import { getConfiguratorReducers } from '../../../../core/state/reducers'; +import { ConfiguratorTestUtils } from '../../../../testing/configurator-test-utils'; +import { ConfigFormUpdateEvent } from '../../../form/configurator-form.event'; import { ConfiguratorStorefrontUtilsService } from '../../../service/configurator-storefront-utils.service'; +import { ConfiguratorAttributeCompositionContext } from '../../composition/configurator-attribute-composition.model'; +import { ConfiguratorAttributePriceChangeService } from '../../price-change/configurator-attribute-price-change.service'; +import { ConfiguratorAttributeQuantityService } from '../../quantity/configurator-attribute-quantity.service'; +import { ConfiguratorAttributeSingleSelectionBaseComponent } from './configurator-attribute-single-selection-base.component'; -function createValue(code: string, name: string, isSelected: boolean) { +const attributeWithValuePrice: Configurator.Attribute = { + name: 'attribute with value price', + label: 'attribute with value price', +}; +const valueWithPrice = createValue('1', 'value with value price', true); +valueWithPrice.valuePrice = { + currencyIso: '$', + formattedValue: '$100.00', + value: 100, +}; + +function createValue( + code: string, + name: string | undefined, + isSelected: boolean | undefined +) { const value: Configurator.Value = { valueCode: code, valueDisplay: name, @@ -50,6 +66,7 @@ class MockConfiguratorCommonsService { @Component({ selector: 'cx-configurator-attribute-single-selection', template: 'test-configurator-attribute-single-selection', + providers: [ConfiguratorAttributePriceChangeService], }) class ExampleConfiguratorAttributeSingleSelectionComponent extends ConfiguratorAttributeSingleSelectionBaseComponent { constructor( @@ -126,6 +143,7 @@ describe('ConfiguratorAttributeSingleSelectionBaseComponent', () => { dataType: Configurator.DataType.USER_SELECTION_QTY_ATTRIBUTE_LEVEL, selectedSingleValue: selectedValue, groupId: groupId, + key: 'attrKey', }; component.ownerKey = ownerKey; fixture.detectChanges(); @@ -480,7 +498,7 @@ describe('ConfiguratorAttributeSingleSelectionBaseComponent', () => { valueWithValuePriceTotal.valuePrice = price; expect( - component.getAriaLabelWithoutAdditionalValue( + component.getAriaLabel( valueWithValuePriceTotal, attributeWithTotalPrice ) @@ -518,7 +536,7 @@ describe('ConfiguratorAttributeSingleSelectionBaseComponent', () => { valueWithValuePriceTotal.valuePrice = price; expect( - component.getAriaLabelWithoutAdditionalValue( + component.getAriaLabel( valueWithValuePriceTotal, attributeWithTotalPrice ) @@ -550,10 +568,7 @@ describe('ConfiguratorAttributeSingleSelectionBaseComponent', () => { valueWithValuePrice.valuePrice = price; expect( - component.getAriaLabelWithoutAdditionalValue( - valueWithValuePrice, - attributeWithValuePrice - ) + component.getAriaLabel(valueWithValuePrice, attributeWithValuePrice) ).toEqual( 'configurator.a11y.selectedValueOfAttributeFullWithPrice attribute:' + attributeWithValuePrice.label + @@ -582,10 +597,7 @@ describe('ConfiguratorAttributeSingleSelectionBaseComponent', () => { valueWithValuePrice.valuePrice = price; expect( - component.getAriaLabelWithoutAdditionalValue( - valueWithValuePrice, - attributeWithValuePrice - ) + component.getAriaLabel(valueWithValuePrice, attributeWithValuePrice) ).toEqual( 'configurator.a11y.valueOfAttributeFullWithPrice attribute:' + attributeWithValuePrice.label + @@ -597,22 +609,19 @@ describe('ConfiguratorAttributeSingleSelectionBaseComponent', () => { }); it('should return aria label for selected value without price', () => { - let attributeWithOutPrice: Configurator.Attribute = { + let attributeWithoutPrice: Configurator.Attribute = { name: 'attribute without price', label: 'attribute without value price', }; - const valueWithOutPrice = createValue('1', 'value without price', true); + const valueWithoutPrice = createValue('1', 'value without price', true); expect( - component.getAriaLabelWithoutAdditionalValue( - valueWithOutPrice, - attributeWithOutPrice - ) + component.getAriaLabel(valueWithoutPrice, attributeWithoutPrice) ).toEqual( 'configurator.a11y.selectedValueOfAttributeFull attribute:' + - attributeWithOutPrice.label + + attributeWithoutPrice.label + ' value:' + - valueWithOutPrice.valueDisplay + valueWithoutPrice.valueDisplay ); }); @@ -624,10 +633,7 @@ describe('ConfiguratorAttributeSingleSelectionBaseComponent', () => { const valueWithOutPrice = createValue('1', 'value without price', false); expect( - component.getAriaLabelWithoutAdditionalValue( - valueWithOutPrice, - attributeWithOutPrice - ) + component.getAriaLabel(valueWithOutPrice, attributeWithOutPrice) ).toEqual( 'configurator.a11y.valueOfAttributeFull attribute:' + attributeWithOutPrice.label + @@ -636,38 +642,63 @@ describe('ConfiguratorAttributeSingleSelectionBaseComponent', () => { ); }); - it('should return aria label for selected value with price and attribute additional value', () => { - let attributeWithValuePrice: Configurator.Attribute = { - name: 'attribute with value price', - label: 'attribute with value price', - }; - let price: Configurator.PriceDetails = { - currencyIso: '$', - formattedValue: '$100.00', - value: 100, + it('should return aria label for value without price in case delta rendering service is not provided', () => { + let attributeWithoutPrice: Configurator.Attribute = { + name: 'attribute without price', + label: 'attribute without value price', }; - const valueWithValuePrice = createValue( - '1', - 'value with value price', - true + const valueWithoutPrice = createValue('1', 'value without price', false); + component['configuratorAttributePriceChangeService'] = null; + expect( + component.getAriaLabel(valueWithoutPrice, attributeWithoutPrice) + ).toEqual( + 'configurator.a11y.valueOfAttributeFull attribute:' + + attributeWithoutPrice.label + + ' value:' + + valueWithoutPrice.valueDisplay ); - valueWithValuePrice.valuePrice = price; + }); + + it('should return aria label for selected value with price and attribute additional value', () => { component.attribute.uiType = Configurator.UiType.DROPDOWN_ADDITIONAL_INPUT || Configurator.UiType.RADIOBUTTON_ADDITIONAL_INPUT; component.attribute.validationType = Configurator.ValidationType.NONE; fixture.detectChanges(); expect( - component.getAriaLabel(valueWithValuePrice, attributeWithValuePrice) + component.getAriaLabel(valueWithPrice, attributeWithValuePrice) ).toEqual( 'configurator.a11y.selectedValueOfAttributeFullWithPrice attribute:' + attributeWithValuePrice.label + - ' price:' + - valueWithValuePrice.valuePrice?.formattedValue + - ' value:' + - valueWithValuePrice.valueDisplay + - ' ' + - 'configurator.a11y.additionalValue' + ' price:$100.00 value:' + + valueWithPrice.valueDisplay + + ' configurator.a11y.additionalValue' + ); + }); + + it('should return aria label for value with price after price was changed', () => { + component.attribute.uiType = + Configurator.UiType.DROPDOWN_ADDITIONAL_INPUT || + Configurator.UiType.RADIOBUTTON_ADDITIONAL_INPUT; + component.attribute.validationType = Configurator.ValidationType.NONE; + + component['configuratorAttributePriceChangeService']?.['storeValuePrice']( + valueWithPrice.name ?? '', + { + currencyIso: '$', + formattedValue: '$200.00', + value: 200, + } + ); + fixture.detectChanges(); + expect( + component.getAriaLabel(valueWithPrice, attributeWithValuePrice) + ).toEqual( + 'configurator.a11y.selectedValueOfAttributeFullWithPrice attribute:' + + attributeWithValuePrice.label + + ' price:$200.00 value:' + + valueWithPrice.valueDisplay + + ' configurator.a11y.additionalValue' ); }); it('should return aria label for value with price and attribute additional value', () => { diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-single-selection-base.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-single-selection-base.component.ts index ecce4e88f7c..95c746b809b 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-single-selection-base.component.ts +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-single-selection-base.component.ts @@ -62,6 +62,10 @@ export abstract class ConfiguratorAttributeSingleSelectionBaseComponent extends false ) ); + this.initPriceChangedEvent( + attributeComponentContext.isPricingAsync, + attributeComponentContext.attribute.key + ); } /** @@ -195,6 +199,10 @@ export abstract class ConfiguratorAttributeSingleSelectionBaseComponent extends extractValuePriceFormulaParameters( value?: Configurator.Value ): ConfiguratorPriceComponentOptions { + if (value && this.configuratorAttributePriceChangeService) { + value = + this.configuratorAttributePriceChangeService.mergePriceIntoValue(value); + } return { price: value?.valuePrice, isLightedUp: value ? value.selected : false, @@ -223,6 +231,10 @@ export abstract class ConfiguratorAttributeSingleSelectionBaseComponent extends value: Configurator.Value, attribute: Configurator.Attribute ): string { + value = + this.configuratorAttributePriceChangeService?.mergePriceIntoValue( + value + ) ?? value; const ariaLabel = this.getAriaLabelWithoutAdditionalValue(value, attribute); if (this.isWithAdditionalValues(this.attribute)) { const ariaLabelWithAdditionalValue = this.getAdditionalValueAriaLabel(); @@ -245,36 +257,6 @@ export abstract class ConfiguratorAttributeSingleSelectionBaseComponent extends value: Configurator.Value, attribute: Configurator.Attribute ): string { - let params; - let translationKey = value.selected - ? 'configurator.a11y.selectedValueOfAttributeFullWithPrice' - : 'configurator.a11y.valueOfAttributeFullWithPrice'; - if (value.valuePriceTotal && value.valuePriceTotal?.value !== 0) { - params = { - value: value.valueDisplay, - attribute: attribute.label, - price: value.valuePriceTotal.formattedValue, - }; - } else if (value.valuePrice && value.valuePrice?.value !== 0) { - params = { - value: value.valueDisplay, - attribute: attribute.label, - price: value.valuePrice.formattedValue, - }; - } else { - translationKey = value.selected - ? 'configurator.a11y.selectedValueOfAttributeFull' - : 'configurator.a11y.valueOfAttributeFull'; - params = { - value: value.valueDisplay, - attribute: attribute.label, - }; - } - let ariaLabel = ''; - this.translation - .translate(translationKey, params) - .pipe(take(1)) - .subscribe((text) => (ariaLabel = text)); - return ariaLabel; + return this.getAriaLabelGeneric(attribute, value, true); } } diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.html index fb9ea5317af..0475f2ac5dc 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.html +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.html @@ -1,6 +1,9 @@
{{ attribute.label }} -
+