From 2c2a8b965fd9f23f99a7dd1176860f56d6d0e0d6 Mon Sep 17 00:00:00 2001 From: Larisa Staroverova Date: Thu, 28 May 2026 10:35:34 +0200 Subject: [PATCH 01/11] feat: CPQ configurator process backed by CPQ 2.0 API (CXSPA-12242) --- .../state/configurator-state-utils.spec.ts | 121 ++++++++++ .../core/state/configurator-state-utils.ts | 58 +++++ .../reducers/configurator.reducer.spec.ts | 122 ++++++++++ .../state/reducers/configurator.reducer.ts | 17 +- .../cpq-configurator-normalizer.spec.ts | 93 +++++++- .../converters/cpq-configurator-normalizer.ts | 59 +++-- .../rulebased/cpq/common/cpq.models.ts | 1 + .../occ/cpq-configurator-occ.adapter.spec.ts | 213 +++++++++++++++++- .../cpq/occ/cpq-configurator-occ.adapter.ts | 76 ++++++- 9 files changed, 734 insertions(+), 26 deletions(-) diff --git a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.spec.ts b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.spec.ts index 4d111d06971..77dcf025abe 100644 --- a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.spec.ts +++ b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.spec.ts @@ -284,4 +284,125 @@ describe('ConfiguratorStateUtils', () => { ); }); }); + + describe('mergeConfigurationGroups', () => { + it('should return existing groups if incoming groups are empty', () => { + const existingGroups = [ConfiguratorTestUtils.createGroup('group1')]; + + expect( + ConfiguratorStateUtils.mergeConfigurationGroups(existingGroups, []) + ).toBe(existingGroups); + }); + + it('should return incoming groups if existing groups are empty', () => { + const incomingGroups = [ConfiguratorTestUtils.createGroup('group1')]; + expect( + ConfiguratorStateUtils.mergeConfigurationGroups([], incomingGroups) + ).toEqual(incomingGroups); + }); + + it('should keep attributes of previously loaded groups when incoming group has no attributes', () => { + const existingAttribute: Configurator.Attribute = { name: 'attr1' }; + const existingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('tab1'), + attributes: [existingAttribute], + }, + ConfiguratorTestUtils.createGroup('tab2'), + ]; + const incomingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('tab1'), + attributes: [], + }, + { + ...ConfiguratorTestUtils.createGroup('tab2'), + attributes: [{ name: 'attr2' }], + }, + ]; + + const mergedGroups = ConfiguratorStateUtils.mergeConfigurationGroups( + existingGroups, + incomingGroups + ); + + expect(mergedGroups[0].attributes).toEqual([existingAttribute]); + expect(mergedGroups[1].attributes?.[0].name).toBe('attr2'); + }); + + it('should keep incoming group unchanged when no existing group matches', () => { + const existingGroups: Configurator.Group[] = [ + ConfiguratorTestUtils.createGroup('existing'), + ]; + const incomingGroup: Configurator.Group = { + ...ConfiguratorTestUtils.createGroup('incoming'), + attributes: [{ name: 'incomingAttr' }], + }; + + const mergedGroups = ConfiguratorStateUtils.mergeConfigurationGroups( + existingGroups, + [incomingGroup] + ); + + expect(mergedGroups[0]).toBe(incomingGroup); + }); + + it('should recursively merge subgroups and preserve loaded subgroup attributes', () => { + const existingSubGroup: Configurator.Group = { + ...ConfiguratorTestUtils.createGroup('sub-1'), + attributes: [{ name: 'persistedAttr' }], + }; + const existingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('root'), + subGroups: [existingSubGroup], + }, + ]; + const incomingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('root'), + subGroups: [ + { + ...ConfiguratorTestUtils.createGroup('sub-1'), + attributes: [], + }, + ], + }, + ]; + + const mergedGroups = ConfiguratorStateUtils.mergeConfigurationGroups( + existingGroups, + incomingGroups + ); + + expect(mergedGroups[0].subGroups[0].attributes?.[0].name).toBe( + 'persistedAttr' + ); + }); + }); + + describe('findGroupById', () => { + it('should find nested group recursively', () => { + const groups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('root'), + subGroups: [ConfiguratorTestUtils.createGroup('nested')], + }, + ]; + + expect( + ConfiguratorStateUtils['findGroupById'](groups, 'nested')?.id + ).toBe('nested'); + }); + + it('should return undefined when group cannot be found', () => { + const groups: Configurator.Group[] = [ + ConfiguratorTestUtils.createGroup('root'), + ]; + + expect( + ConfiguratorStateUtils['findGroupById'](groups, 'missing') + ).toBeUndefined(); + }); + }); }); diff --git a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts index 51e22b20642..874a1bcd162 100644 --- a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts +++ b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts @@ -7,6 +7,64 @@ import { Configurator } from '../model/configurator.model'; export class ConfiguratorStateUtils { + static mergeConfigurationGroups( + existingGroups: Configurator.Group[], + incomingGroups: Configurator.Group[] + ): Configurator.Group[] { + if (!incomingGroups?.length) { + return existingGroups; + } + if (!existingGroups?.length) { + return incomingGroups; + } + + return incomingGroups.map((incomingGroup) => { + const existingGroup = this.findGroupById( + existingGroups, + incomingGroup.id + ); + if (!existingGroup) { + return incomingGroup; + } + + return { + ...existingGroup, + ...incomingGroup, + attributes: incomingGroup.attributes?.length + ? incomingGroup.attributes + : existingGroup.attributes, + subGroups: this.mergeConfigurationGroups( + existingGroup.subGroups ?? [], + incomingGroup.subGroups ?? [] + ), + }; + }); + } + + protected static findGroupById( + groups: Configurator.Group[], + groupId: string + ): Configurator.Group | undefined { + const group = groups.find((currentGroup) => currentGroup.id === groupId); + if (group) { + return group; + } + + for (const currentGroup of groups) { + if (currentGroup.subGroups?.length) { + const groupFromSubGroups = this.findGroupById( + currentGroup.subGroups, + groupId + ); + if (groupFromSubGroups) { + return groupFromSubGroups; + } + } + } + + return undefined; + } + static mergeGroupsWithSupplements( groups: Configurator.Group[], attributeSupplements: Configurator.AttributeSupplement[] diff --git a/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.spec.ts b/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.spec.ts index b468b71e3b0..f25f147dbde 100644 --- a/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.spec.ts +++ b/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.spec.ts @@ -230,6 +230,48 @@ describe('Configurator reducer', () => { CONFIGURATION.groups[0].id ); }); + + it('should merge tab attributes without clearing previously loaded tabs', () => { + const tab1Group: Configurator.Group = { + ...ConfiguratorTestUtils.createGroup('tab1'), + attributes: [{ name: 'attr-tab1' }], + }; + const tab2GroupEmpty: Configurator.Group = + ConfiguratorTestUtils.createGroup('tab2'); + + const initialConfiguration: Configurator.Configuration = { + ...CONFIGURATION, + groups: [tab1Group, tab2GroupEmpty], + }; + const stateAfterFirstTab = StateReduce.configuratorReducer( + undefined, + new ConfiguratorActions.ReadConfigurationSuccess(initialConfiguration) + ); + + const tab2LoadedConfiguration: Configurator.Configuration = { + ...CONFIGURATION, + groups: [ + { ...tab1Group, attributes: [] }, + { + ...tab2GroupEmpty, + attributes: [{ name: 'attr-tab2' }], + }, + ], + }; + const stateAfterSecondTab = StateReduce.configuratorReducer( + stateAfterFirstTab, + new ConfiguratorActions.ReadConfigurationSuccess( + tab2LoadedConfiguration + ) + ); + + expect(stateAfterSecondTab.groups[0].attributes?.[0].name).toBe( + 'attr-tab1' + ); + expect(stateAfterSecondTab.groups[1].attributes?.[0].name).toBe( + 'attr-tab2' + ); + }); }); describe('UpdateConfigurationSuccess action', () => { @@ -490,6 +532,25 @@ describe('Configurator reducer', () => { expect(state.interactionState.currentGroup).toEqual(CURRENT_GROUP); }); + + it('should trigger conflict solver dialog when immediate conflict resolution is active and configuration is inconsistent', () => { + const state = StateReduce.configuratorReducer( + { + ...CONFIGURATION, + immediateConflictResolution: true, + consistent: false, + interactionState: {}, + }, + new ConfiguratorActions.SetCurrentGroup({ + entityKey: PRODUCT_CODE, + currentGroup: CURRENT_GROUP, + }) + ); + + expect(state.interactionState.currentGroup).toEqual(CURRENT_GROUP); + expect(state.interactionState.showConflictSolverDialog).toBe(true); + expect(state.interactionState.issueNavigationDone).toBe(true); + }); }); describe('SetMenuParentGroup action', () => { @@ -552,6 +613,24 @@ describe('Configurator reducer', () => { group4: true, }); }); + + it('should reduce Group Visited if existing visited groups are undefined', () => { + const initialState = { + ...StateReduce.initialState, + interactionState: {}, + }; + + const action = new ConfiguratorActions.SetGroupsVisited({ + entityKey: PRODUCT_CODE, + visitedGroups: ['group4'], + }); + + const state = StateReduce.configuratorReducer(initialState, action); + + expect(state.interactionState.groupsVisited).toEqual({ + group4: true, + }); + }); }); describe('SearchVariantsSuccess action', () => { @@ -639,6 +718,35 @@ describe('Configurator reducer', () => { expect(state.overview).toEqual(overview); }); + + it('should copy price summary and reset issueNavigationDone', () => { + const priceSummary: Configurator.PriceSummary = {}; + const overview: Configurator.Overview = { + configId: CONFIG_ID, + productCode: PRODUCT_CODE, + priceSummary, + }; + const action = new ConfiguratorActions.UpdateConfigurationOverviewSuccess( + { + ownerKey: CONFIGURATION.owner.key, + overview: overview, + } + ); + const stateWithIssueNavigationDone = { + ...StateReduce.initialState, + interactionState: { + ...StateReduce.initialState.interactionState, + issueNavigationDone: true, + }, + }; + const state = StateReduce.configuratorReducer( + stateWithIssueNavigationDone, + action + ); + + expect(state.priceSummary).toBe(priceSummary); + expect(state.interactionState.issueNavigationDone).toBe(false); + }); }); describe('ReadOrderEntryConfigurationSuccess action', () => { @@ -742,5 +850,19 @@ describe('Configurator reducer', () => { expect(state.interactionState.showConflictSolverDialog).toBe(true); }); + + it('should disable conflict solver dialog for consistent configurations', () => { + const action = new ConfiguratorActions.CheckConflictDialoge(OWNER.key); + const state = StateReduce.configuratorReducer( + { + ...CONFIGURATION_IMMEDIATE_CONFLICT_RESOLUTION, + consistent: true, + interactionState: {}, + }, + action + ); + + expect(state.interactionState.showConflictSolverDialog).toBe(false); + }); }); }); diff --git a/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.ts b/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.ts index c3e9677815c..49bbc4c53e1 100644 --- a/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.ts +++ b/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.ts @@ -11,6 +11,7 @@ import { import { Configurator } from '../../model/configurator.model'; import { ConfiguratorActions } from '../actions/index'; +import { ConfiguratorStateUtils } from '../configurator-state-utils'; export const initialState: Configurator.Configuration = { configId: '', @@ -442,12 +443,26 @@ function takeOverChanges( state: Configurator.Configuration ): Configurator.Configuration { const content = { ...action.payload }; - const groups = content.groups.length > 0 ? content.groups : state.groups; + const groups = + content.groups.length > 0 + ? ConfiguratorStateUtils.mergeConfigurationGroups( + state.groups, + content.groups + ) + : state.groups; + const flatGroups = + content.flatGroups.length > 0 + ? ConfiguratorStateUtils.mergeConfigurationGroups( + state.flatGroups, + content.flatGroups + ) + : state.flatGroups; const result: Configurator.Configuration = { ...state, ...content, groups: groups, + flatGroups: flatGroups, interactionState: { ...state.interactionState, ...content.interactionState, diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts index 21932901bf1..b2f825e86fc 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts @@ -223,7 +223,7 @@ describe('CpqConfiguratorNormalizer', () => { expect(result.totalNumberOfIssues).toBe(0); expect(result.groups.length).toBe(2); expect(result.groups[0].id).toBe(cpqGroupId.toString()); - expect(result.groups[0].attributes?.length).toBe(1); + expect(result.groups[0].attributes?.length).toBe(0); expect(result.groups[1].id).toBe(cpqGroupId2.toString()); expect(result.groups[1].attributes?.length).toBe(0); expect(result.priceSummary?.currentTotal?.formattedValue).toBe( @@ -236,6 +236,31 @@ describe('CpqConfiguratorNormalizer', () => { expect(result.configId).toBe(''); }); + it('should use tab attributes for CPQ version V2 or higher', () => { + const result = cpqConfiguratorNormalizer.convert({ + ...cpqConfiguration, + version: 'V2', + tabs: [ + { + ...cpqTab, + attributes: [cpqAttribute], + }, + { + ...cpqTab2, + attributes: [cpqAttribute2], + }, + ], + }); + expect(result.groups[0].attributes?.length).toBe(1); + expect(result.groups[0].attributes?.[0].name).toBe( + cpqAttributePaId.toString() + ); + expect(result.groups[1].attributes?.length).toBe(1); + expect(result.groups[1].attributes?.[0].name).toBe( + cpqAttributePaId2.toString() + ); + }); + it('should set configuration id if provided', () => { const result = cpqConfiguratorNormalizer.convert({ ...cpqConfiguration, @@ -1235,6 +1260,72 @@ describe('CpqConfiguratorNormalizer', () => { ).toBe(false); }); }); + + describe('hasFullTabAttributes', () => { + it('should return false if version is undefined', () => { + expect(cpqConfiguratorNormalizer['hasFullTabAttributes'](undefined)).toBe( + false + ); + }); + + it('should return true if version is V2 or higher', () => { + expect(cpqConfiguratorNormalizer['hasFullTabAttributes'](' V2 ')).toBe( + true + ); + expect(cpqConfiguratorNormalizer['hasFullTabAttributes']('v3')).toBe( + true + ); + }); + + it('should return false if version is invalid', () => { + expect(cpqConfiguratorNormalizer['hasFullTabAttributes']('invalid')).toBe( + false + ); + }); + }); + + describe('getTabAttributes', () => { + it('should return tab attributes when version supports full tab payload', () => { + const source: Cpq.Configuration = { + ...cpqConfiguration, + version: 'V2', + }; + const tab: Cpq.Tab = { + ...cpqTab2, + isSelected: false, + attributes: [cpqAttribute2], + }; + const result = cpqConfiguratorNormalizer['getTabAttributes'](source, tab); + expect(result).toEqual([cpqAttribute2]); + }); + + it('should return global source attributes for selected tab when version is not defined', () => { + const source: Cpq.Configuration = { + ...cpqConfiguration, + version: undefined, + }; + const tab: Cpq.Tab = { + ...cpqTab, + isSelected: true, + attributes: [cpqAttribute2], + }; + const result = cpqConfiguratorNormalizer['getTabAttributes'](source, tab); + expect(result).toEqual(source.attributes); + }); + + it('should return empty array for non-selected tab when version is not defined', () => { + const source: Cpq.Configuration = { + ...cpqConfiguration, + version: undefined, + }; + const tab: Cpq.Tab = { + ...cpqTab2, + isSelected: false, + }; + const result = cpqConfiguratorNormalizer['getTabAttributes'](source, tab); + expect(result).toEqual([]); + }); + }); describe('generateErrorMessages', () => { it('should create no error message for incomplete attribute', () => { const messageObs = cpqConfiguratorNormalizer['generateErrorMessages']( diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts index 7f26d94d8c8..b425a5f3d1a 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts @@ -16,6 +16,8 @@ import { CpqConfiguratorNormalizerUtilsService } from './cpq-configurator-normal export class CpqConfiguratorNormalizer implements Converter { + protected static readonly VERSION_WITH_FULL_TAB_ATTRIBUTES = 2; + constructor( protected cpqConfiguratorNormalizerUtilsService: CpqConfiguratorNormalizerUtilsService, protected translation: TranslationService @@ -25,6 +27,7 @@ export class CpqConfiguratorNormalizer source: Cpq.Configuration, target?: Configurator.Configuration ): Configurator.Configuration { + source.version = 'V2'; const resultTarget: Configurator.Configuration = { ...target, configId: source.configurationId ? source.configurationId : '', //if empty, will later be populated with final value @@ -46,10 +49,11 @@ export class CpqConfiguratorNormalizer warningMessages: this.generateWarningMessages(source), pricingEnabled: true, }; + source.tabs?.forEach((tab) => this.convertGroup( tab, - source.attributes ?? [], + this.getTabAttributes(source, tab), source.currencyISOCode, resultTarget.groups, resultTarget.flatGroups @@ -70,13 +74,13 @@ export class CpqConfiguratorNormalizer } protected generateTotalNumberOfIssues(source: Cpq.Configuration): number { - const numberOfIssues: number = + return ( (source.incompleteAttributes?.length ?? 0) + (source.incompleteMessages?.length ?? 0) + (source.invalidMessages?.length ?? 0) + (source.failedValidations?.length ?? 0) + - (source.errorMessages?.length ?? 0); - return numberOfIssues; + (source.errorMessages?.length ?? 0) + ); } protected generateWarningMessages(source: Cpq.Configuration): string[] { @@ -101,11 +105,9 @@ export class CpqConfiguratorNormalizer flatGroupList: Configurator.Group[] ) { const attributes: Configurator.Attribute[] = []; - if (source.isSelected) { - sourceAttributes.forEach((sourceAttribute) => - this.convertAttribute(sourceAttribute, source.id, currency, attributes) - ); - } + sourceAttributes.forEach((sourceAttribute) => + this.convertAttribute(sourceAttribute, source.id, currency, attributes) + ); const group: Configurator.Group = { id: source.id.toString(), @@ -223,7 +225,7 @@ export class CpqConfiguratorNormalizer const selectedValues = values .map((entry) => entry) .filter((entry) => entry.selected); - if (selectedValues && selectedValues.length === 1) { + if (selectedValues?.length === 1) { attribute.selectedSingleValue = selectedValues[0].valueCode; } } @@ -295,13 +297,12 @@ export class CpqConfiguratorNormalizer ): Configurator.UiType { const displayAs = sourceAttribute.displayAs; - const displayAsProduct: boolean = + const displayAsProduct: boolean = !!( sourceAttribute.values && this.cpqConfiguratorNormalizerUtilsService.hasAnyProducts( sourceAttribute.values ) - ? true - : false; + ); const isEnabled: boolean = sourceAttribute.isEnabled ?? false; if ( @@ -395,10 +396,7 @@ export class CpqConfiguratorNormalizer case Configurator.UiType.CHECKBOX: case Configurator.UiType.MULTI_SELECTION_IMAGE: { const isOneValueSelected = - attribute.values?.find((value) => value.selected) !== undefined - ? true - : false; - + attribute.values?.find((value) => value.selected) !== undefined; if (!isOneValueSelected) { attribute.incomplete = true; } @@ -423,4 +421,31 @@ export class CpqConfiguratorNormalizer false ); } + + protected hasFullTabAttributes(version?: string): boolean { + if (!version) { + return false; + } + + const normalizedVersion = version.trim().toUpperCase(); + const majorVersion = Number.parseInt( + normalizedVersion.replace('V', ''), + 10 + ); + + return ( + !Number.isNaN(majorVersion) && + majorVersion >= CpqConfiguratorNormalizer.VERSION_WITH_FULL_TAB_ATTRIBUTES + ); + } + + protected getTabAttributes( + source: Cpq.Configuration, + tab: Cpq.Tab + ): Cpq.Attribute[] { + if (this.hasFullTabAttributes(source.version)) { + return tab.attributes ?? []; + } + return tab.isSelected ? (source.attributes ?? []) : []; + } } diff --git a/feature-libs/product-configurator/rulebased/cpq/common/cpq.models.ts b/feature-libs/product-configurator/rulebased/cpq/common/cpq.models.ts index 23dbc71fe3b..ba6cf18485e 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/cpq.models.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/cpq.models.ts @@ -39,6 +39,7 @@ export namespace Cpq { tabs?: Tab[]; attributes?: Attribute[]; // attributes of current selected tab configurationId?: string; + version?: string; } /** diff --git a/feature-libs/product-configurator/rulebased/cpq/occ/cpq-configurator-occ.adapter.spec.ts b/feature-libs/product-configurator/rulebased/cpq/occ/cpq-configurator-occ.adapter.spec.ts index f860ecba559..b7186fcbf36 100644 --- a/feature-libs/product-configurator/rulebased/cpq/occ/cpq-configurator-occ.adapter.spec.ts +++ b/feature-libs/product-configurator/rulebased/cpq/occ/cpq-configurator-occ.adapter.spec.ts @@ -1,13 +1,19 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { Type } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { provideMockStore } from '@ngrx/store/testing'; import { CartModification } from '@spartacus/cart/base/root'; +import { StateUtils } from '@spartacus/core'; import { CommonConfigurator, ConfiguratorModelUtils, ConfiguratorType, } from '@spartacus/product-configurator/common'; -import { Configurator } from '@spartacus/product-configurator/rulebased'; +import { + CONFIGURATOR_FEATURE, + Configurator, + StateWithConfigurator, +} from '@spartacus/product-configurator/rulebased'; import { of } from 'rxjs'; import { ConfiguratorTestUtils } from '../../testing/configurator-test-utils'; import { CpqConfiguratorOccAdapter } from './cpq-configurator-occ.adapter'; @@ -85,6 +91,29 @@ const readConfigOrderEntryParams: CommonConfigurator.ReadConfigurationFromOrderE const asSpy = (f: any) => f; +function getMockConfiguratorState( + configuration?: Configurator.Configuration +): StateWithConfigurator { + const entities: { + [id: string]: StateUtils.ProcessesLoaderState; + } = {}; + if (configuration) { + entities[configuration.owner.key] = { + processesCount: 0, + loading: false, + error: false, + value: configuration, + success: true, + }; + } + + return { + [CONFIGURATOR_FEATURE]: { + configurations: { entities }, + }, + }; +} + describe('CpqConfiguratorOccAdapter', () => { let adapterUnderTest: CpqConfiguratorOccAdapter; let mockedOccService: CpqConfiguratorOccService; @@ -151,6 +180,9 @@ describe('CpqConfiguratorOccAdapter', () => { provide: CpqConfiguratorOccService, useValue: mockedOccService, }, + provideMockStore({ + initialState: getMockConfiguratorState(), + }), provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), ], @@ -190,6 +222,185 @@ describe('CpqConfiguratorOccAdapter', () => { }); }); + it('should read configuration from store if tab was loaded before for V2+', () => { + const loadedGroupId = 'loaded-tab'; + const configurationWithLoadedTab: Configurator.Configuration = { + ...productConfiguration, + groups: [ + { + ...ConfiguratorTestUtils.createGroup(loadedGroupId), + attributes: [{ name: 'attr1' }], + }, + ], + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + CpqConfiguratorOccAdapter, + { + provide: CpqConfiguratorOccService, + useValue: mockedOccService, + }, + provideMockStore({ + initialState: getMockConfiguratorState(configurationWithLoadedTab), + }), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + const adapter = TestBed.inject(CpqConfiguratorOccAdapter); + asSpy(mockedOccService.readConfiguration).calls.reset(); + + adapter + .readConfiguration( + configurationWithLoadedTab.configId, + loadedGroupId, + owner + ) + .subscribe((config) => { + expect(config.owner).toEqual(owner); + expect(config.groups[0].attributes?.length).toBe(1); + expect(config.interactionState.currentGroup).toBe(loadedGroupId); + expect(mockedOccService.readConfiguration).not.toHaveBeenCalled(); + }); + }); + + it('should call OCC read if configuration ID does not match store entry', () => { + const loadedGroupId = 'loaded-tab'; + const configurationWithLoadedTab: Configurator.Configuration = { + ...productConfiguration, + groups: [ + { + ...ConfiguratorTestUtils.createGroup(loadedGroupId), + attributes: [{ name: 'attr1' }], + }, + ], + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + CpqConfiguratorOccAdapter, + { + provide: CpqConfiguratorOccService, + useValue: mockedOccService, + }, + provideMockStore({ + initialState: getMockConfiguratorState(configurationWithLoadedTab), + }), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + const adapter = TestBed.inject(CpqConfiguratorOccAdapter); + asSpy(mockedOccService.readConfiguration).calls.reset(); + + adapter + .readConfiguration('different-config-id', loadedGroupId, owner) + .subscribe(); + + expect(mockedOccService.readConfiguration).toHaveBeenCalledWith( + 'different-config-id', + loadedGroupId + ); + }); + + it('should call OCC read if requested group has no attributes in store', () => { + const loadedGroupId = 'loaded-tab'; + const configurationWithEmptyGroup: Configurator.Configuration = { + ...productConfiguration, + groups: [ + { + ...ConfiguratorTestUtils.createGroup(loadedGroupId), + attributes: [], + }, + ], + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + CpqConfiguratorOccAdapter, + { + provide: CpqConfiguratorOccService, + useValue: mockedOccService, + }, + provideMockStore({ + initialState: getMockConfiguratorState(configurationWithEmptyGroup), + }), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + const adapter = TestBed.inject(CpqConfiguratorOccAdapter); + asSpy(mockedOccService.readConfiguration).calls.reset(); + + adapter + .readConfiguration( + configurationWithEmptyGroup.configId, + loadedGroupId, + owner + ) + .subscribe(); + + expect(mockedOccService.readConfiguration).toHaveBeenCalledWith( + configurationWithEmptyGroup.configId, + loadedGroupId + ); + }); + + it('should call OCC read if requested group is missing in store', () => { + const loadedGroupId = 'loaded-tab'; + const missingGroupId = 'missing-tab'; + const configurationWithoutRequestedGroup: Configurator.Configuration = { + ...productConfiguration, + groups: [ + { + ...ConfiguratorTestUtils.createGroup(loadedGroupId), + attributes: [], + }, + ], + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + CpqConfiguratorOccAdapter, + { + provide: CpqConfiguratorOccService, + useValue: mockedOccService, + }, + provideMockStore({ + initialState: getMockConfiguratorState( + configurationWithoutRequestedGroup + ), + }), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + const adapter = TestBed.inject(CpqConfiguratorOccAdapter); + asSpy(mockedOccService.readConfiguration).calls.reset(); + + adapter + .readConfiguration( + configurationWithoutRequestedGroup.configId, + missingGroupId, + owner + ) + .subscribe(); + + expect(mockedOccService.readConfiguration).toHaveBeenCalledWith( + configurationWithoutRequestedGroup.configId, + missingGroupId + ); + }); + // this ensures that there is a dummy response until the API is implemented, // otherwise this leads to an NPE on the UI it('should always return same configuration for price summary', () => { diff --git a/feature-libs/product-configurator/rulebased/cpq/occ/cpq-configurator-occ.adapter.ts b/feature-libs/product-configurator/rulebased/cpq/occ/cpq-configurator-occ.adapter.ts index e402b4cbea2..c93ed490c85 100644 --- a/feature-libs/product-configurator/rulebased/cpq/occ/cpq-configurator-occ.adapter.ts +++ b/feature-libs/product-configurator/rulebased/cpq/occ/cpq-configurator-occ.adapter.ts @@ -5,6 +5,7 @@ */ import { Injectable } from '@angular/core'; +import { Store, select } from '@ngrx/store'; import { CartModification } from '@spartacus/cart/base/root'; import { CommonConfigurator, @@ -12,15 +13,22 @@ import { } from '@spartacus/product-configurator/common'; import { Configurator, + ConfiguratorSelectors, + ConfiguratorUtilsService, RulebasedConfiguratorAdapter, + StateWithConfigurator, } from '@spartacus/product-configurator/rulebased'; import { Observable, of } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, switchMap, take } from 'rxjs/operators'; import { CpqConfiguratorOccService } from './cpq-configurator-occ.service'; @Injectable() export class CpqConfiguratorOccAdapter implements RulebasedConfiguratorAdapter { - constructor(protected cpqOccService: CpqConfiguratorOccService) {} + constructor( + protected cpqOccService: CpqConfiguratorOccService, + protected store: Store, + protected configuratorUtilsService: ConfiguratorUtilsService + ) {} getConfiguratorType(): string { return ConfiguratorType.CPQ; @@ -44,14 +52,70 @@ export class CpqConfiguratorOccAdapter implements RulebasedConfiguratorAdapter { groupId: string, owner: CommonConfigurator.Owner ): Observable { - return this.cpqOccService.readConfiguration(configId, groupId).pipe( - map((configResponse) => { - configResponse.owner = owner; - return configResponse; + return this.store.pipe( + select(ConfiguratorSelectors.getConfigurationFactory(owner.key)), + take(1), + switchMap((configuration) => { + const configurationFromStore = + this.getConfigurationFromStoreIfTabLoaded( + configuration, + configId, + groupId, + owner + ); + if (configurationFromStore) { + return of(configurationFromStore); + } + + return this.cpqOccService.readConfiguration(configId, groupId).pipe( + map((configResponse) => { + configResponse.owner = owner; + return configResponse; + }) + ); }) ); } + /** + * Retrieves the configuration from the store if the requested tab (group) + * was loaded before and still has attributes. + * + * @param configuration - current configuration + * @param configId - configuration ID + * @param groupId - group ID of the requested tab + * @param owner - configuration owner + * @returns configuration from the store or undefined if the tab was not loaded before or has no attributes + */ + protected getConfigurationFromStoreIfTabLoaded( + configuration: Configurator.Configuration, + configId: string, + groupId: string, + owner: CommonConfigurator.Owner + ): Configurator.Configuration | undefined { + if (!configuration || !groupId || configuration.configId !== configId) { + return undefined; + } + + const group = this.configuratorUtilsService.getGroupById( + configuration.groups, + groupId + ); + + if (!group?.attributes?.length) { + return undefined; + } + + return { + ...configuration, + owner, + interactionState: { + ...configuration.interactionState, + currentGroup: groupId, + }, + }; + } + updateConfiguration( configuration: Configurator.Configuration ): Observable { From faaaf371f8335fec486fc43a3defc827972b4581 Mon Sep 17 00:00:00 2001 From: Larisa Staroverova Date: Thu, 28 May 2026 11:06:46 +0200 Subject: [PATCH 02/11] feat: Refactoring I --- .../state/configurator-state-utils.spec.ts | 8 ++-- .../core/state/configurator-state-utils.ts | 15 +++---- .../reducers/configurator.reducer.spec.ts | 42 +++++++++---------- .../cpq-configurator-normalizer.spec.ts | 1 + 4 files changed, 31 insertions(+), 35 deletions(-) diff --git a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.spec.ts b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.spec.ts index 77dcf025abe..9a661e237b7 100644 --- a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.spec.ts +++ b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.spec.ts @@ -305,18 +305,18 @@ describe('ConfiguratorStateUtils', () => { const existingAttribute: Configurator.Attribute = { name: 'attr1' }; const existingGroups: Configurator.Group[] = [ { - ...ConfiguratorTestUtils.createGroup('tab1'), + ...ConfiguratorTestUtils.createGroup('group1'), attributes: [existingAttribute], }, - ConfiguratorTestUtils.createGroup('tab2'), + ConfiguratorTestUtils.createGroup('group2'), ]; const incomingGroups: Configurator.Group[] = [ { - ...ConfiguratorTestUtils.createGroup('tab1'), + ...ConfiguratorTestUtils.createGroup('group1'), attributes: [], }, { - ...ConfiguratorTestUtils.createGroup('tab2'), + ...ConfiguratorTestUtils.createGroup('group2'), attributes: [{ name: 'attr2' }], }, ]; diff --git a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts index 874a1bcd162..775475bc025 100644 --- a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts +++ b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts @@ -45,19 +45,16 @@ export class ConfiguratorStateUtils { groups: Configurator.Group[], groupId: string ): Configurator.Group | undefined { - const group = groups.find((currentGroup) => currentGroup.id === groupId); + const group = groups.find((group) => group.id === groupId); if (group) { return group; } - for (const currentGroup of groups) { - if (currentGroup.subGroups?.length) { - const groupFromSubGroups = this.findGroupById( - currentGroup.subGroups, - groupId - ); - if (groupFromSubGroups) { - return groupFromSubGroups; + for (const group of groups) { + if (group.subGroups?.length) { + const subgroups = this.findGroupById(group.subGroups, groupId); + if (subgroups) { + return subgroups; } } } diff --git a/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.spec.ts b/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.spec.ts index f25f147dbde..f1992bcd243 100644 --- a/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.spec.ts +++ b/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.spec.ts @@ -231,45 +231,43 @@ describe('Configurator reducer', () => { ); }); - it('should merge tab attributes without clearing previously loaded tabs', () => { - const tab1Group: Configurator.Group = { - ...ConfiguratorTestUtils.createGroup('tab1'), - attributes: [{ name: 'attr-tab1' }], + it('should merge group attributes without clearing previously loaded groups', () => { + const group1: Configurator.Group = { + ...ConfiguratorTestUtils.createGroup('group1'), + attributes: [{ name: 'attr-group1' }], }; - const tab2GroupEmpty: Configurator.Group = - ConfiguratorTestUtils.createGroup('tab2'); + const group2: Configurator.Group = + ConfiguratorTestUtils.createGroup('group2'); const initialConfiguration: Configurator.Configuration = { ...CONFIGURATION, - groups: [tab1Group, tab2GroupEmpty], + groups: [group1, group2], }; - const stateAfterFirstTab = StateReduce.configuratorReducer( + const stateAfterFirstGroup = StateReduce.configuratorReducer( undefined, new ConfiguratorActions.ReadConfigurationSuccess(initialConfiguration) ); - const tab2LoadedConfiguration: Configurator.Configuration = { + const loadedConfiguration: Configurator.Configuration = { ...CONFIGURATION, groups: [ - { ...tab1Group, attributes: [] }, + { ...group1, attributes: [] }, { - ...tab2GroupEmpty, - attributes: [{ name: 'attr-tab2' }], + ...group2, + attributes: [{ name: 'attr-group2' }], }, ], }; - const stateAfterSecondTab = StateReduce.configuratorReducer( - stateAfterFirstTab, - new ConfiguratorActions.ReadConfigurationSuccess( - tab2LoadedConfiguration - ) + const stateAfterSecondGroup = StateReduce.configuratorReducer( + stateAfterFirstGroup, + new ConfiguratorActions.ReadConfigurationSuccess(loadedConfiguration) ); - expect(stateAfterSecondTab.groups[0].attributes?.[0].name).toBe( - 'attr-tab1' + expect(stateAfterSecondGroup.groups[0].attributes?.[0].name).toBe( + 'attr-group1' ); - expect(stateAfterSecondTab.groups[1].attributes?.[0].name).toBe( - 'attr-tab2' + expect(stateAfterSecondGroup.groups[1].attributes?.[0].name).toBe( + 'attr-group2' ); }); }); @@ -614,7 +612,7 @@ describe('Configurator reducer', () => { }); }); - it('should reduce Group Visited if existing visited groups are undefined', () => { + it('should reduce group visited if existing visited groups are undefined', () => { const initialState = { ...StateReduce.initialState, interactionState: {}, diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts index b2f825e86fc..c4da1c43491 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts @@ -1326,6 +1326,7 @@ describe('CpqConfiguratorNormalizer', () => { expect(result).toEqual([]); }); }); + describe('generateErrorMessages', () => { it('should create no error message for incomplete attribute', () => { const messageObs = cpqConfiguratorNormalizer['generateErrorMessages']( From 26991a700dbdefdcdf272c1f17584822e5e238f2 Mon Sep 17 00:00:00 2001 From: Larisa Staroverova Date: Thu, 28 May 2026 15:49:47 +0200 Subject: [PATCH 03/11] feat: Refactoring II --- .../configurator-basic-effect.service.ts | 18 ++++++++---------- .../effects/configurator-basic.effect.spec.ts | 12 +++--------- .../state/effects/configurator-basic.effect.ts | 11 ++++++++++- .../cpq-configurator-normalizer.spec.ts | 1 + .../converters/cpq-configurator-normalizer.ts | 1 - 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic-effect.service.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic-effect.service.ts index b349e2bdd7d..3d7f1fde97a 100644 --- a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic-effect.service.ts +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic-effect.service.ts @@ -61,16 +61,14 @@ export class ConfiguratorBasicEffectService { ) .shift(); } - if (groupWithAttributes === undefined) { - groupWithAttributes = groups - .filter( - (currentGroup) => - currentGroup.attributes && - currentGroup.attributes.length > 0 && - currentGroup.groupType !== Configurator.GroupType.CONFLICT_GROUP - ) - .shift(); - } + groupWithAttributes ??= groups + .filter( + (currentGroup) => + currentGroup.attributes && + currentGroup.attributes.length > 0 && + currentGroup.groupType !== Configurator.GroupType.CONFLICT_GROUP + ) + .shift(); let id: string | undefined; if (groupWithAttributes) { id = groupWithAttributes.id; diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts index f08841df27c..3100124d04f 100644 --- a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts @@ -671,7 +671,7 @@ describe('ConfiguratorEffect', () => { ); }); - it('should raise UpdateConfigurationFinalize, UpdatePrices and ChangeGroup with group id of conflict group in case conflicts exist but current group is a conflict group', () => { + it('should raise UpdateConfigurationFinalize, UpdatePrices and SearchVariants without ChangeGroup when current group is already a conflict group', () => { store.dispatch( new ConfiguratorActions.SetCurrentGroup({ entityKey: productConfiguration.owner.key, @@ -688,23 +688,17 @@ describe('ConfiguratorEffect', () => { ); const updatePrices = new ConfiguratorActions.UpdatePriceSummary({ ...productConfigurationWithConflict, - interactionState: { currentGroup: GROUP_ID_CONFLICT_1 }, + interactionState: { currentGroup: GROUP_ID_CONFLICT_2 }, }); const searchVariantsAction = new ConfiguratorActions.SearchVariants( productConfigurationWithConflict ); - const changeGroup = new ConfiguratorActions.ChangeGroup({ - configuration: productConfigurationWithConflict, - groupId: GROUP_ID_CONFLICT_1, - parentGroupId: GROUP_ID_CONFLICT_HEADER, - }); actions$ = hot('-a', { a: action }); - const expected = cold('-(bcde)', { + const expected = cold('-(bcd)', { b: finalizeSuccess, c: updatePrices, d: searchVariantsAction, - e: changeGroup, }); expect(configEffects.updateConfigurationSuccess$).toBeObservable( expected diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts index d0bac0a0c37..183f05913d5 100644 --- a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts @@ -314,8 +314,8 @@ export class ConfiguratorBasicEffects { ), take(1), map((currentGroupId) => { - // Group ids of conflict groups (Configurator.GroupType.CONFLICT_GROUP) always start with 'CONFLICT' const groupIdFromPayload = + currentGroupId ?? this.configuratorBasicEffectService.getFirstGroupWithAttributes( payload, payload.interactionState.isConflictResolutionMode @@ -329,6 +329,15 @@ export class ConfiguratorBasicEffects { ), undefined ); + console.log('currentGroupId: ', currentGroupId); + console.log('groupIdFromPayload: ', groupIdFromPayload); + console.log( + 'parentGroupFromPayload: ', + parentGroupFromPayload + ); + console.log( + '#####################################################' + ); return { currentGroupId, groupIdFromPayload, diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts index c4da1c43491..0eccecac272 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts @@ -129,6 +129,7 @@ const cpqConfiguration: Cpq.Configuration = { currencyISOCode: 'USD', currencySign: '$', responder: { totalPrice: '$3333.33', baseProductPrice: '1000' }, + version: 'V2', }; const ERROR_MSG = 'This is an error message'; diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts index b425a5f3d1a..9fd7eb2a1a7 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts @@ -27,7 +27,6 @@ export class CpqConfiguratorNormalizer source: Cpq.Configuration, target?: Configurator.Configuration ): Configurator.Configuration { - source.version = 'V2'; const resultTarget: Configurator.Configuration = { ...target, configId: source.configurationId ? source.configurationId : '', //if empty, will later be populated with final value From 6f4434ace37f538dbb81d3a12ac098f6c5d4038c Mon Sep 17 00:00:00 2001 From: Larisa Staroverova Date: Fri, 29 May 2026 10:24:10 +0200 Subject: [PATCH 04/11] feat: Refactoring III --- .../effects/configurator-basic.effect.spec.ts | 12 ++++++--- .../effects/configurator-basic.effect.ts | 25 ++++++++++++------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts index 3100124d04f..95cbdf70ff8 100644 --- a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts @@ -671,7 +671,7 @@ describe('ConfiguratorEffect', () => { ); }); - it('should raise UpdateConfigurationFinalize, UpdatePrices and SearchVariants without ChangeGroup when current group is already a conflict group', () => { + it('should raise UpdateConfigurationFinalize, UpdatePrices and ChangeGroup when current group is a conflict group id', () => { store.dispatch( new ConfiguratorActions.SetCurrentGroup({ entityKey: productConfiguration.owner.key, @@ -688,17 +688,23 @@ describe('ConfiguratorEffect', () => { ); const updatePrices = new ConfiguratorActions.UpdatePriceSummary({ ...productConfigurationWithConflict, - interactionState: { currentGroup: GROUP_ID_CONFLICT_2 }, + interactionState: { currentGroup: GROUP_ID_CONFLICT_1 }, }); const searchVariantsAction = new ConfiguratorActions.SearchVariants( productConfigurationWithConflict ); + const changeGroup = new ConfiguratorActions.ChangeGroup({ + configuration: productConfigurationWithConflict, + groupId: GROUP_ID_CONFLICT_1, + parentGroupId: GROUP_ID_CONFLICT_HEADER, + }); actions$ = hot('-a', { a: action }); - const expected = cold('-(bcd)', { + const expected = cold('-(bcde)', { b: finalizeSuccess, c: updatePrices, d: searchVariantsAction, + e: changeGroup, }); expect(configEffects.updateConfigurationSuccess$).toBeObservable( expected diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts index 183f05913d5..0505a141e0a 100644 --- a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts @@ -306,7 +306,7 @@ export class ConfiguratorBasicEffects { return this.store.pipe( select(ConfiguratorSelectors.hasPendingChanges(payload.owner.key)), take(1), - filter((hasPendingChanges) => hasPendingChanges === false), + filter((hasPendingChanges) => !hasPendingChanges), switchMap(() => this.store.pipe( select( @@ -314,8 +314,14 @@ export class ConfiguratorBasicEffects { ), take(1), map((currentGroupId) => { + const applicableCurrentGroupId = + currentGroupId && + !currentGroupId.startsWith(Configurator.ConflictIdPrefix) + ? currentGroupId + : undefined; + const groupIdFromPayload = - currentGroupId ?? + applicableCurrentGroupId ?? this.configuratorBasicEffectService.getFirstGroupWithAttributes( payload, payload.interactionState.isConflictResolutionMode @@ -326,10 +332,10 @@ export class ConfiguratorBasicEffects { this.configuratorGroupUtilsService.getGroupById( payload.groups, groupIdFromPayload - ), - undefined + ) ); - console.log('currentGroupId: ', currentGroupId); + + console.log('currentGroupId: ', applicableCurrentGroupId); console.log('groupIdFromPayload: ', groupIdFromPayload); console.log( 'parentGroupFromPayload: ', @@ -338,8 +344,9 @@ export class ConfiguratorBasicEffects { console.log( '#####################################################' ); + return { - currentGroupId, + applicableCurrentGroupId, groupIdFromPayload, parentGroupFromPayload, }; @@ -360,7 +367,7 @@ export class ConfiguratorBasicEffects { }); const searchVariantsAction = new ConfiguratorActions.SearchVariants(payload); - return container.currentGroupId === + return container.applicableCurrentGroupId === container.groupIdFromPayload ? [ updateFinalizeSuccessAction, @@ -401,7 +408,7 @@ export class ConfiguratorBasicEffects { ) ), take(1), - filter((hasPendingChanges) => hasPendingChanges === false), + filter((hasPendingChanges) => !hasPendingChanges), map( () => new ConfiguratorActions.UpdateConfigurationFinalizeFail( @@ -451,7 +458,7 @@ export class ConfiguratorBasicEffects { ) ), take(1), - filter((hasPendingChanges) => hasPendingChanges === false), + filter((hasPendingChanges) => !hasPendingChanges), switchMap(() => { return this.configuratorCommonsConnector .readConfiguration( From f217915675398a4628441c5eae12c65c92f31a2d Mon Sep 17 00:00:00 2001 From: Larisa Staroverova Date: Fri, 29 May 2026 14:01:10 +0200 Subject: [PATCH 05/11] CXSPA-12242: Make sonar happy --- .../rulebased/core/state/configurator-state-utils.ts | 8 ++++---- .../core/state/effects/configurator-basic.effect.ts | 10 ---------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts index 775475bc025..7aacd718a39 100644 --- a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts +++ b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts @@ -45,14 +45,14 @@ export class ConfiguratorStateUtils { groups: Configurator.Group[], groupId: string ): Configurator.Group | undefined { - const group = groups.find((group) => group.id === groupId); + const group = groups.find((g) => g.id === groupId); if (group) { return group; } - for (const group of groups) { - if (group.subGroups?.length) { - const subgroups = this.findGroupById(group.subGroups, groupId); + for (const g of groups) { + if (g.subGroups?.length) { + const subgroups = this.findGroupById(g.subGroups, groupId); if (subgroups) { return subgroups; } diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts index 0505a141e0a..eede3860338 100644 --- a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts @@ -335,16 +335,6 @@ export class ConfiguratorBasicEffects { ) ); - console.log('currentGroupId: ', applicableCurrentGroupId); - console.log('groupIdFromPayload: ', groupIdFromPayload); - console.log( - 'parentGroupFromPayload: ', - parentGroupFromPayload - ); - console.log( - '#####################################################' - ); - return { applicableCurrentGroupId, groupIdFromPayload, From 7ce9fb65c44a9d0dafee6a60d62cc4acc0e4ab48 Mon Sep 17 00:00:00 2001 From: Larisa Staroverova Date: Tue, 9 Jun 2026 08:46:41 +0200 Subject: [PATCH 06/11] CXSPA-13470: Refactoring I --- .../cpq-configurator-normalizer.spec.ts | 33 +++---------------- .../converters/cpq-configurator-normalizer.ts | 19 +---------- .../rulebased/cpq/common/cpq.models.ts | 2 +- 3 files changed, 7 insertions(+), 47 deletions(-) diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts index 0eccecac272..2cc3d3fa5d8 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts @@ -129,7 +129,7 @@ const cpqConfiguration: Cpq.Configuration = { currencyISOCode: 'USD', currencySign: '$', responder: { totalPrice: '$3333.33', baseProductPrice: '1000' }, - version: 'V2', + hasFullConfigurationState: true, }; const ERROR_MSG = 'This is an error message'; @@ -240,7 +240,7 @@ describe('CpqConfiguratorNormalizer', () => { it('should use tab attributes for CPQ version V2 or higher', () => { const result = cpqConfiguratorNormalizer.convert({ ...cpqConfiguration, - version: 'V2', + hasFullConfigurationState: true, tabs: [ { ...cpqTab, @@ -1262,34 +1262,11 @@ describe('CpqConfiguratorNormalizer', () => { }); }); - describe('hasFullTabAttributes', () => { - it('should return false if version is undefined', () => { - expect(cpqConfiguratorNormalizer['hasFullTabAttributes'](undefined)).toBe( - false - ); - }); - - it('should return true if version is V2 or higher', () => { - expect(cpqConfiguratorNormalizer['hasFullTabAttributes'](' V2 ')).toBe( - true - ); - expect(cpqConfiguratorNormalizer['hasFullTabAttributes']('v3')).toBe( - true - ); - }); - - it('should return false if version is invalid', () => { - expect(cpqConfiguratorNormalizer['hasFullTabAttributes']('invalid')).toBe( - false - ); - }); - }); - describe('getTabAttributes', () => { it('should return tab attributes when version supports full tab payload', () => { const source: Cpq.Configuration = { ...cpqConfiguration, - version: 'V2', + hasFullConfigurationState: true, }; const tab: Cpq.Tab = { ...cpqTab2, @@ -1303,7 +1280,7 @@ describe('CpqConfiguratorNormalizer', () => { it('should return global source attributes for selected tab when version is not defined', () => { const source: Cpq.Configuration = { ...cpqConfiguration, - version: undefined, + hasFullConfigurationState: undefined, }; const tab: Cpq.Tab = { ...cpqTab, @@ -1317,7 +1294,7 @@ describe('CpqConfiguratorNormalizer', () => { it('should return empty array for non-selected tab when version is not defined', () => { const source: Cpq.Configuration = { ...cpqConfiguration, - version: undefined, + hasFullConfigurationState: false, }; const tab: Cpq.Tab = { ...cpqTab2, diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts index 9fd7eb2a1a7..76fcee28ed3 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts @@ -421,28 +421,11 @@ export class CpqConfiguratorNormalizer ); } - protected hasFullTabAttributes(version?: string): boolean { - if (!version) { - return false; - } - - const normalizedVersion = version.trim().toUpperCase(); - const majorVersion = Number.parseInt( - normalizedVersion.replace('V', ''), - 10 - ); - - return ( - !Number.isNaN(majorVersion) && - majorVersion >= CpqConfiguratorNormalizer.VERSION_WITH_FULL_TAB_ATTRIBUTES - ); - } - protected getTabAttributes( source: Cpq.Configuration, tab: Cpq.Tab ): Cpq.Attribute[] { - if (this.hasFullTabAttributes(source.version)) { + if (source.hasFullConfigurationState) { return tab.attributes ?? []; } return tab.isSelected ? (source.attributes ?? []) : []; diff --git a/feature-libs/product-configurator/rulebased/cpq/common/cpq.models.ts b/feature-libs/product-configurator/rulebased/cpq/common/cpq.models.ts index ba6cf18485e..0f525078398 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/cpq.models.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/cpq.models.ts @@ -39,7 +39,7 @@ export namespace Cpq { tabs?: Tab[]; attributes?: Attribute[]; // attributes of current selected tab configurationId?: string; - version?: string; + hasFullConfigurationState?: boolean; } /** From 2e732ffaa6cafe3bd293fd5e0368bd1d2dd16b51 Mon Sep 17 00:00:00 2001 From: Larisa Staroverova Date: Mon, 29 Jun 2026 14:17:20 +0200 Subject: [PATCH 07/11] CXSPA-13470: Enable retract option for CPQV2 --- .../cpq-configurator-normalizer.spec.ts | 212 +++++++++++++++++- .../converters/cpq-configurator-normalizer.ts | 76 +++++-- 2 files changed, 270 insertions(+), 18 deletions(-) diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts index 2cc3d3fa5d8..cf2cf037971 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts @@ -596,7 +596,8 @@ describe('CpqConfiguratorNormalizer', () => { expect(attribute.dataType).toBe(configuratorAttributeDataType); const values = attribute.values; - expect(values?.length).toBe(2); + expect(values?.length).toBe(3); + expect(values?.[0].valueCode).toBe(Configurator.RetractValueCode); }); it('should convert attributes with values - with many sysId', () => { @@ -1401,6 +1402,215 @@ describe('CpqConfiguratorNormalizer', () => { }); }); + describe('isAttributeTypeReadOnly', () => { + it('should return true for READ_ONLY ui type', () => { + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + uiType: Configurator.UiType.READ_ONLY, + }; + expect( + cpqConfiguratorNormalizer['isAttributeTypeReadOnly'](attribute) + ).toBe(true); + }); + + it('should return false for non READ_ONLY ui type', () => { + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + uiType: Configurator.UiType.RADIOBUTTON, + }; + expect( + cpqConfiguratorNormalizer['isAttributeTypeReadOnly'](attribute) + ).toBe(false); + }); + }); + + describe('isRetractValueSelected', () => { + it('should return true when no value is selected', () => { + const cpqAttr: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + values: [ + { paV_ID: 1, selected: false }, + { paV_ID: 2, selected: false }, + ], + }; + expect(cpqConfiguratorNormalizer['isRetractValueSelected'](cpqAttr)).toBe( + true + ); + }); + + it('should return true when values are undefined', () => { + const cpqAttr: Cpq.Attribute = { pA_ID: 1, stdAttrCode: 2 }; + expect(cpqConfiguratorNormalizer['isRetractValueSelected'](cpqAttr)).toBe( + true + ); + }); + + it('should return false when at least one value is selected', () => { + const cpqAttr: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + values: [ + { paV_ID: 1, selected: false }, + { paV_ID: 2, selected: true }, + ], + }; + expect(cpqConfiguratorNormalizer['isRetractValueSelected'](cpqAttr)).toBe( + false + ); + }); + }); + + describe('setRetractValueDisplay', () => { + it('should use drop-down select message for selected DROPDOWN', () => { + const value: Configurator.Value = { valueCode: '0', selected: true }; + cpqConfiguratorNormalizer['setRetractValueDisplay']( + Configurator.UiType.DROPDOWN, + value + ); + expect(value.valueDisplay).toBe('Make a selection'); + }); + + it('should use no option selected message for non-selected DROPDOWN', () => { + const value: Configurator.Value = { valueCode: '0', selected: false }; + cpqConfiguratorNormalizer['setRetractValueDisplay']( + Configurator.UiType.DROPDOWN, + value + ); + expect(value.valueDisplay).toBe('General'); + }); + + it('should use no option selected message for non-dropdown types', () => { + const value: Configurator.Value = { valueCode: '0', selected: true }; + cpqConfiguratorNormalizer['setRetractValueDisplay']( + Configurator.UiType.RADIOBUTTON, + value + ); + expect(value.valueDisplay).toBe('General'); + }); + }); + + describe('addRetractValue', () => { + it('should add a retract value for RADIOBUTTON attributes', () => { + const sourceAttribute: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + values: [{ paV_ID: 1, selected: false }], + }; + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + uiType: Configurator.UiType.RADIOBUTTON, + }; + const values: Configurator.Value[] = []; + cpqConfiguratorNormalizer['addRetractValue']( + sourceAttribute, + attribute, + values + ); + expect(values.length).toBe(1); + expect(values[0].valueCode).toBe(Configurator.RetractValueCode); + expect(values[0].selected).toBe(true); + }); + + it('should add a retract value for DROPDOWN and SINGLE_SELECTION_IMAGE attributes', () => { + const sourceAttribute: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + values: [{ paV_ID: 1, selected: true }], + }; + [ + Configurator.UiType.DROPDOWN, + Configurator.UiType.SINGLE_SELECTION_IMAGE, + ].forEach((uiType) => { + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + uiType, + }; + const values: Configurator.Value[] = []; + cpqConfiguratorNormalizer['addRetractValue']( + sourceAttribute, + attribute, + values + ); + expect(values.length).toBe(1); + expect(values[0].selected).toBe(false); + }); + }); + + it('should not add a retract value for READ_ONLY attributes', () => { + const sourceAttribute: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + values: [{ paV_ID: 1, selected: false }], + }; + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + uiType: Configurator.UiType.READ_ONLY, + }; + const values: Configurator.Value[] = []; + cpqConfiguratorNormalizer['addRetractValue']( + sourceAttribute, + attribute, + values + ); + expect(values.length).toBe(0); + }); + + it('should not add a retract value for unsupported ui types', () => { + const sourceAttribute: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + values: [{ paV_ID: 1, selected: false }], + }; + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + uiType: Configurator.UiType.CHECKBOXLIST, + }; + const values: Configurator.Value[] = []; + cpqConfiguratorNormalizer['addRetractValue']( + sourceAttribute, + attribute, + values + ); + expect(values.length).toBe(0); + }); + }); + + describe('generateWarningMessages', () => { + it('should return empty array when no warnings are present', () => { + const result = + cpqConfiguratorNormalizer['generateWarningMessages'](cpqConfiguration); + expect(result).toEqual([]); + }); + + it('should concat failed validations and incomplete messages', () => { + const result = cpqConfiguratorNormalizer['generateWarningMessages']({ + ...cpqConfiguration, + failedValidations: [VALIDATION_MSG], + incompleteMessages: [INCOMPLETE_MSG], + }); + expect(result).toEqual([VALIDATION_MSG, INCOMPLETE_MSG]); + }); + }); + + describe('generateTotalNumberOfIssues', () => { + it('should return 0 when no issues are present', () => { + expect( + cpqConfiguratorNormalizer['generateTotalNumberOfIssues']( + cpqConfiguration + ) + ).toBe(0); + }); + + it('should sum up all issue sources', () => { + expect( + cpqConfiguratorNormalizer['generateTotalNumberOfIssues']( + cpqConfigurationIncompleteInconsistent + ) + ).toBe(6); + }); + }); + describe('mapPAId', () => { it("should map standard field name 'pA_ID' if present", () => { expect( diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts index 76fcee28ed3..88ca456ad1d 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts @@ -16,8 +16,6 @@ import { CpqConfiguratorNormalizerUtilsService } from './cpq-configurator-normal export class CpqConfiguratorNormalizer implements Converter { - protected static readonly VERSION_WITH_FULL_TAB_ATTRIBUTES = 2; - constructor( protected cpqConfiguratorNormalizerUtilsService: CpqConfiguratorNormalizerUtilsService, protected translation: TranslationService @@ -29,7 +27,7 @@ export class CpqConfiguratorNormalizer ): Configurator.Configuration { const resultTarget: Configurator.Configuration = { ...target, - configId: source.configurationId ? source.configurationId : '', //if empty, will later be populated with final value + configId: source.configurationId ?? '', //if empty, will later be populated with final value complete: !source.incompleteAttributes?.length, consistent: !source.invalidMessages?.length && @@ -83,17 +81,14 @@ export class CpqConfiguratorNormalizer } protected generateWarningMessages(source: Cpq.Configuration): string[] { - let warnMsgs: string[] = []; - warnMsgs = warnMsgs.concat(source.failedValidations ?? []); - warnMsgs = warnMsgs.concat(source.incompleteMessages ?? []); - return warnMsgs; + return [ + ...(source.failedValidations ?? []), + ...(source.incompleteMessages ?? []), + ]; } protected generateErrorMessages(source: Cpq.Configuration): string[] { - let errorMsgs: string[] = []; - errorMsgs = errorMsgs.concat(source.errorMessages ?? []); - errorMsgs = errorMsgs.concat(source.invalidMessages ?? []); - return errorMsgs; + return [...(source.errorMessages ?? []), ...(source.invalidMessages ?? [])]; } protected convertGroup( @@ -155,6 +150,54 @@ export class CpqConfiguratorNormalizer flatGroupList.push(group); } + protected isAttributeTypeReadOnly( + attribute: Configurator.Attribute + ): boolean { + return attribute.uiType === Configurator.UiType.READ_ONLY; + } + + protected isRetractValueSelected(sourceAttribute: Cpq.Attribute): boolean { + return !sourceAttribute.values?.filter((value) => value.selected).length; + } + + protected setRetractValueDisplay( + attributeType: Configurator.UiType, + value: Configurator.Value + ) { + if (attributeType === Configurator.UiType.DROPDOWN && value.selected) { + this.translation + .translate('configurator.attribute.dropDownSelectMsg') + .pipe(take(1)) + .subscribe((text) => (value.valueDisplay = text)); + } else { + this.translation + .translate('configurator.attribute.noOptionSelectedMsg') + .pipe(take(1)) + .subscribe((text) => (value.valueDisplay = text)); + } + } + + protected addRetractValue( + sourceAttribute: Cpq.Attribute, + attribute: Configurator.Attribute, + values: Configurator.Value[] + ) { + if (!this.isAttributeTypeReadOnly(attribute)) { + if ( + attribute.uiType === Configurator.UiType.RADIOBUTTON || + attribute.uiType === Configurator.UiType.DROPDOWN || + attribute.uiType === Configurator.UiType.SINGLE_SELECTION_IMAGE + ) { + const value: Configurator.Value = { + valueCode: Configurator.RetractValueCode, + selected: this.isRetractValueSelected(sourceAttribute), + }; + this.setRetractValueDisplay(attribute.uiType, value); + values.push(value); + } + } + } + protected convertAttribute( sourceAttribute: Cpq.Attribute, groupId: number, @@ -190,6 +233,7 @@ export class CpqConfiguratorNormalizer sourceAttribute.displayAs !== Cpq.DisplayAs.INPUT ) { const values: Configurator.Value[] = []; + this.addRetractValue(sourceAttribute, attribute, values); sourceAttribute.values.forEach((value) => this.convertValue(value, sourceAttribute, currency, values) ); @@ -221,9 +265,7 @@ export class CpqConfiguratorNormalizer protected setSelectedSingleValue(attribute: Configurator.Attribute) { const values = attribute.values; if (values) { - const selectedValues = values - .map((entry) => entry) - .filter((entry) => entry.selected); + const selectedValues = values.filter((entry) => entry.selected); if (selectedValues?.length === 1) { attribute.selectedSingleValue = selectedValues[0].valueCode; } @@ -408,9 +450,9 @@ export class CpqConfiguratorNormalizer attribute: Cpq.Attribute, value: Cpq.Value ): boolean { - const selectedValues = attribute.values - ?.map((entry) => entry) - .filter((entry) => entry.selected && entry.paV_ID !== 0); + const selectedValues = attribute.values?.filter( + (entry) => entry.selected && entry.paV_ID !== 0 + ); return ( (attribute.displayAs === Cpq.DisplayAs.DROPDOWN && attribute.required && From d4aa2dd5c3355ce9825c6c26d8f510edd41427c6 Mon Sep 17 00:00:00 2001 From: Larisa Staroverova Date: Wed, 1 Jul 2026 08:22:46 +0200 Subject: [PATCH 08/11] CXSPA-13470: Refactoring I --- .../cpq-configurator-normalizer.spec.ts | 237 +++++++++++++++--- .../converters/cpq-configurator-normalizer.ts | 54 ++-- 2 files changed, 243 insertions(+), 48 deletions(-) diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts index cf2cf037971..0dba43f34c0 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts @@ -177,10 +177,8 @@ class MockTranslationService { translate(key: string, options: any): Observable { if (key.endsWith('incomplete')) { return of(TEST_MESSAGE + options.attribute); - } else if (key.indexOf('dropDownSelectMsg') >= 0) { - return of('Make a selection'); } else { - return of('General'); + return of(key); } } } @@ -634,7 +632,7 @@ describe('CpqConfiguratorNormalizer', () => { }); const values = attribute.values; - expect(values?.length).toBe(2); + expect(values?.length).toBe(3); }); it('should convert attributes with values - with only 1 sysId', () => { @@ -674,7 +672,7 @@ describe('CpqConfiguratorNormalizer', () => { expect(attribute.dataType).toBe(configuratorAttributeDataType); const values = attribute.values; - expect(values?.length).toBe(2); + expect(values?.length).toBe(3); }); it('should convert attributes without values', () => { @@ -787,7 +785,7 @@ describe('CpqConfiguratorNormalizer', () => { const group: Configurator.Group = groups[0]; expect(group.id).toBe('1'); expect(group.name).toBe('_GEN'); - expect(group.description).toBe('General'); + expect(group.description).toBe('configurator.group.general'); expect(group.configurable).toBe(true); expect(group.complete).toBe(false); expect(group.consistent).toBe(true); @@ -998,6 +996,14 @@ describe('CpqConfiguratorNormalizer', () => { expect(configAttribute.selectedSingleValue).toBeUndefined(); }); + it('should not set selectedSingleValue when attribute has no values', () => { + const configAttribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + }; + cpqConfiguratorNormalizer['setSelectedSingleValue'](configAttribute); + expect(configAttribute.selectedSingleValue).toBeUndefined(); + }); + describe('compileAttributeIncomplete', () => { it('should set incomplete by radio button, dropdown and single-selection-image type correctly', () => { const attributeRBWithValues: Configurator.Attribute = { @@ -1382,7 +1388,33 @@ describe('CpqConfiguratorNormalizer', () => { cpqAttr, value ); - expect(value.valueDisplay).toEqual('Make a selection'); + expect(value.valueDisplay).toEqual( + 'configurator.attribute.dropDownSelectMsg' + ); + }); + + it('should convert value display - contain cpq value display for selected real drop-down value (paV_ID not 0)', () => { + const mockCpqValue: Cpq.Value = { + paV_ID: 5, + valueDisplay: 'Red', + selected: true, + }; + const cpqAttr: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + displayAs: Cpq.DisplayAs.DROPDOWN, + required: true, + values: [mockCpqValue], + }; + const value: Configurator.Value = { + valueCode: mockCpqValue.paV_ID.toString(), + }; + cpqConfiguratorNormalizer['convertValueDisplay']( + mockCpqValue, + cpqAttr, + value + ); + expect(value.valueDisplay).toEqual(mockCpqValue.valueDisplay); }); it('should convert value display - contain cpq value display for drop-down list', () => { @@ -1402,15 +1434,15 @@ describe('CpqConfiguratorNormalizer', () => { }); }); - describe('isAttributeTypeReadOnly', () => { + describe('isUITypeReadOnly', () => { it('should return true for READ_ONLY ui type', () => { const attribute: Configurator.Attribute = { name: 'ATTRIBUTE_NAME', uiType: Configurator.UiType.READ_ONLY, }; - expect( - cpqConfiguratorNormalizer['isAttributeTypeReadOnly'](attribute) - ).toBe(true); + expect(cpqConfiguratorNormalizer['isUITypeReadOnly'](attribute)).toBe( + true + ); }); it('should return false for non READ_ONLY ui type', () => { @@ -1418,13 +1450,66 @@ describe('CpqConfiguratorNormalizer', () => { name: 'ATTRIBUTE_NAME', uiType: Configurator.UiType.RADIOBUTTON, }; + expect(cpqConfiguratorNormalizer['isUITypeReadOnly'](attribute)).toBe( + false + ); + }); + }); + + describe('isSingleSelectionUiType', () => { + function expectSingleSelectionUiType( + uiType: Configurator.UiType, + expected: boolean + ): void { + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + uiType, + }; expect( - cpqConfiguratorNormalizer['isAttributeTypeReadOnly'](attribute) - ).toBe(false); + cpqConfiguratorNormalizer['isSingleSelectionUiType'](attribute) + ).toBe(expected); + } + + it('should return true for RADIOBUTTON ui type', () => { + expectSingleSelectionUiType(Configurator.UiType.RADIOBUTTON, true); + }); + + it('should return true for DROPDOWN ui type', () => { + expectSingleSelectionUiType(Configurator.UiType.DROPDOWN, true); + }); + + it('should return true for SINGLE_SELECTION_IMAGE ui type', () => { + expectSingleSelectionUiType( + Configurator.UiType.SINGLE_SELECTION_IMAGE, + true + ); + }); + + it('should return true for DROPDOWN_PRODUCT ui type', () => { + expectSingleSelectionUiType(Configurator.UiType.DROPDOWN_PRODUCT, true); + }); + + it('should return true for RADIOBUTTON_PRODUCT ui type', () => { + expectSingleSelectionUiType( + Configurator.UiType.RADIOBUTTON_PRODUCT, + true + ); + }); + + it('should return false for CHECKBOX ui type', () => { + expectSingleSelectionUiType(Configurator.UiType.CHECKBOXLIST, false); + }); + + it('should return false for STRING ui type', () => { + expectSingleSelectionUiType(Configurator.UiType.STRING, false); + }); + + it('should return false for READ_ONLY ui type', () => { + expectSingleSelectionUiType(Configurator.UiType.READ_ONLY, false); }); }); - describe('isRetractValueSelected', () => { + describe('isThereSelectedValue', () => { it('should return true when no value is selected', () => { const cpqAttr: Cpq.Attribute = { pA_ID: 1, @@ -1434,14 +1519,14 @@ describe('CpqConfiguratorNormalizer', () => { { paV_ID: 2, selected: false }, ], }; - expect(cpqConfiguratorNormalizer['isRetractValueSelected'](cpqAttr)).toBe( + expect(cpqConfiguratorNormalizer['isThereSelectedValue'](cpqAttr)).toBe( true ); }); it('should return true when values are undefined', () => { const cpqAttr: Cpq.Attribute = { pA_ID: 1, stdAttrCode: 2 }; - expect(cpqConfiguratorNormalizer['isRetractValueSelected'](cpqAttr)).toBe( + expect(cpqConfiguratorNormalizer['isThereSelectedValue'](cpqAttr)).toBe( true ); }); @@ -1455,7 +1540,44 @@ describe('CpqConfiguratorNormalizer', () => { { paV_ID: 2, selected: true }, ], }; - expect(cpqConfiguratorNormalizer['isRetractValueSelected'](cpqAttr)).toBe( + expect(cpqConfiguratorNormalizer['isThereSelectedValue'](cpqAttr)).toBe( + false + ); + }); + }); + + describe('isThereAnyRetractValue', () => { + it('should return true when a value with paV_ID 0 (retract) is present', () => { + const cpqAttr: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + values: [ + { paV_ID: 0, selected: false }, + { paV_ID: 1, selected: true }, + ], + }; + expect(cpqConfiguratorNormalizer['isThereAnyRetractValue'](cpqAttr)).toBe( + true + ); + }); + + it('should return false when no value with paV_ID 0 is present', () => { + const cpqAttr: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + values: [ + { paV_ID: 1, selected: false }, + { paV_ID: 2, selected: true }, + ], + }; + expect(cpqConfiguratorNormalizer['isThereAnyRetractValue'](cpqAttr)).toBe( + false + ); + }); + + it('should return false when values are undefined', () => { + const cpqAttr: Cpq.Attribute = { pA_ID: 1, stdAttrCode: 2 }; + expect(cpqConfiguratorNormalizer['isThereAnyRetractValue'](cpqAttr)).toBe( false ); }); @@ -1464,29 +1586,67 @@ describe('CpqConfiguratorNormalizer', () => { describe('setRetractValueDisplay', () => { it('should use drop-down select message for selected DROPDOWN', () => { const value: Configurator.Value = { valueCode: '0', selected: true }; - cpqConfiguratorNormalizer['setRetractValueDisplay']( - Configurator.UiType.DROPDOWN, - value + const attribute: Configurator.Attribute = { + name: 'attr_1', + uiType: Configurator.UiType.DROPDOWN, + values: [value], + }; + cpqConfiguratorNormalizer['setRetractValueDisplay'](attribute, value); + expect(value.valueDisplay).toBe( + 'configurator.attribute.dropDownSelectMsg' + ); + }); + + it('should use drop-down select message for selected DROPDOWN_PRODUCT', () => { + const value: Configurator.Value = { valueCode: '0', selected: true }; + const attribute: Configurator.Attribute = { + name: 'attr_2', + uiType: Configurator.UiType.DROPDOWN_PRODUCT, + values: [value], + }; + cpqConfiguratorNormalizer['setRetractValueDisplay'](attribute, value); + expect(value.valueDisplay).toBe( + 'configurator.attribute.dropDownSelectMsg' ); - expect(value.valueDisplay).toBe('Make a selection'); }); it('should use no option selected message for non-selected DROPDOWN', () => { const value: Configurator.Value = { valueCode: '0', selected: false }; - cpqConfiguratorNormalizer['setRetractValueDisplay']( - Configurator.UiType.DROPDOWN, - value + const attribute: Configurator.Attribute = { + name: 'attr_2', + uiType: Configurator.UiType.DROPDOWN, + values: [value], + }; + cpqConfiguratorNormalizer['setRetractValueDisplay'](attribute, value); + expect(value.valueDisplay).toBe( + 'configurator.attribute.noOptionSelectedMsg' + ); + }); + + it('should use drop-down select message for non-selected DROPDOWN_PRODUCT', () => { + const value: Configurator.Value = { valueCode: '0', selected: false }; + const attribute: Configurator.Attribute = { + name: 'attr_2', + uiType: Configurator.UiType.DROPDOWN_PRODUCT, + values: [value], + }; + cpqConfiguratorNormalizer['setRetractValueDisplay'](attribute, value); + expect(value.valueDisplay).toBe( + 'configurator.attribute.noOptionSelectedMsg' ); - expect(value.valueDisplay).toBe('General'); }); it('should use no option selected message for non-dropdown types', () => { const value: Configurator.Value = { valueCode: '0', selected: true }; - cpqConfiguratorNormalizer['setRetractValueDisplay']( - Configurator.UiType.RADIOBUTTON, - value + const attribute: Configurator.Attribute = { + name: 'attr_3', + uiType: Configurator.UiType.RADIOBUTTON, + values: [value], + }; + cpqConfiguratorNormalizer['setRetractValueDisplay'](attribute, value); + expect(value.valueDisplay).toBe( + 'configurator.attribute.noOptionSelectedMsg' ); - expect(value.valueDisplay).toBe('General'); }); }); @@ -1556,6 +1716,25 @@ describe('CpqConfiguratorNormalizer', () => { expect(values.length).toBe(0); }); + it('should not add a retract value when a retract value (paV_ID 0) is already present', () => { + const sourceAttribute: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + values: [{ paV_ID: 0, selected: false }], + }; + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + uiType: Configurator.UiType.RADIOBUTTON, + }; + const values: Configurator.Value[] = []; + cpqConfiguratorNormalizer['addRetractValue']( + sourceAttribute, + attribute, + values + ); + expect(values.length).toBe(0); + }); + it('should not add a retract value for unsupported ui types', () => { const sourceAttribute: Cpq.Attribute = { pA_ID: 1, diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts index 88ca456ad1d..f28c127b6f7 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts @@ -150,21 +150,27 @@ export class CpqConfiguratorNormalizer flatGroupList.push(group); } - protected isAttributeTypeReadOnly( - attribute: Configurator.Attribute - ): boolean { + protected isUITypeReadOnly(attribute: Configurator.Attribute): boolean { return attribute.uiType === Configurator.UiType.READ_ONLY; } - protected isRetractValueSelected(sourceAttribute: Cpq.Attribute): boolean { + protected isThereAnyRetractValue(sourceAttribute: Cpq.Attribute): boolean { + return sourceAttribute.values?.some((value) => value.paV_ID === 0) ?? false; + } + + protected isThereSelectedValue(sourceAttribute: Cpq.Attribute): boolean { return !sourceAttribute.values?.filter((value) => value.selected).length; } protected setRetractValueDisplay( - attributeType: Configurator.UiType, + attribute: Configurator.Attribute, value: Configurator.Value ) { - if (attributeType === Configurator.UiType.DROPDOWN && value.selected) { + if ( + (attribute.uiType === Configurator.UiType.DROPDOWN || + attribute.uiType === Configurator.UiType.DROPDOWN_PRODUCT) && + value.selected + ) { this.translation .translate('configurator.attribute.dropDownSelectMsg') .pipe(take(1)) @@ -177,24 +183,34 @@ export class CpqConfiguratorNormalizer } } + protected isSingleSelectionUiType( + attribute: Configurator.Attribute + ): boolean { + return ( + attribute.uiType === Configurator.UiType.RADIOBUTTON || + attribute.uiType === Configurator.UiType.DROPDOWN || + attribute.uiType === Configurator.UiType.SINGLE_SELECTION_IMAGE || + attribute.uiType === Configurator.UiType.DROPDOWN_PRODUCT || + attribute.uiType === Configurator.UiType.RADIOBUTTON_PRODUCT + ); + } + protected addRetractValue( sourceAttribute: Cpq.Attribute, attribute: Configurator.Attribute, values: Configurator.Value[] ) { - if (!this.isAttributeTypeReadOnly(attribute)) { - if ( - attribute.uiType === Configurator.UiType.RADIOBUTTON || - attribute.uiType === Configurator.UiType.DROPDOWN || - attribute.uiType === Configurator.UiType.SINGLE_SELECTION_IMAGE - ) { - const value: Configurator.Value = { - valueCode: Configurator.RetractValueCode, - selected: this.isRetractValueSelected(sourceAttribute), - }; - this.setRetractValueDisplay(attribute.uiType, value); - values.push(value); - } + if ( + !this.isUITypeReadOnly(attribute) && + !this.isThereAnyRetractValue(sourceAttribute) && + this.isSingleSelectionUiType(attribute) + ) { + const value: Configurator.Value = { + valueCode: Configurator.RetractValueCode, + selected: this.isThereSelectedValue(sourceAttribute), + }; + this.setRetractValueDisplay(attribute, value); + values.push(value); } } From e29b88a18fe53d328d0aaac88a8c743ff57c395c Mon Sep 17 00:00:00 2001 From: LarisaStar <61147963+Larisa-Staroverova@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:55:33 +0200 Subject: [PATCH 09/11] Update feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts Co-authored-by: Christoph Hinssen <33626130+ChristophHi@users.noreply.github.com> --- .../core/state/effects/configurator-basic.effect.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts index 95cbdf70ff8..bb4199e503f 100644 --- a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts @@ -671,7 +671,7 @@ describe('ConfiguratorEffect', () => { ); }); - it('should raise UpdateConfigurationFinalize, UpdatePrices and ChangeGroup when current group is a conflict group id', () => { + it('should raise UpdateConfigurationFinalize, UpdatePrices and ChangeGroup when current group is a conflict group', () => { store.dispatch( new ConfiguratorActions.SetCurrentGroup({ entityKey: productConfiguration.owner.key, From 8374700b0414e924362ff51368a6cbcb4f2a01d9 Mon Sep 17 00:00:00 2001 From: Larisa Staroverova Date: Wed, 1 Jul 2026 14:12:28 +0200 Subject: [PATCH 10/11] CXSPA-13470: Refactor retract logic --- .../state/configurator-state-utils.spec.ts | 143 ++++++++++++++++++ .../cpq-configurator-normalizer.spec.ts | 127 ++++++++++++---- .../converters/cpq-configurator-normalizer.ts | 43 +++++- 3 files changed, 278 insertions(+), 35 deletions(-) diff --git a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.spec.ts b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.spec.ts index 9a661e237b7..3b7d9730248 100644 --- a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.spec.ts +++ b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.spec.ts @@ -294,6 +294,17 @@ describe('ConfiguratorStateUtils', () => { ).toBe(existingGroups); }); + it('should return existing groups if incoming groups are undefined', () => { + const existingGroups = [ConfiguratorTestUtils.createGroup('group1')]; + + expect( + ConfiguratorStateUtils.mergeConfigurationGroups( + existingGroups, + undefined as unknown as Configurator.Group[] + ) + ).toBe(existingGroups); + }); + it('should return incoming groups if existing groups are empty', () => { const incomingGroups = [ConfiguratorTestUtils.createGroup('group1')]; expect( @@ -301,6 +312,138 @@ describe('ConfiguratorStateUtils', () => { ).toEqual(incomingGroups); }); + it('should return incoming groups if existing groups are undefined', () => { + const incomingGroups = [ConfiguratorTestUtils.createGroup('group1')]; + expect( + ConfiguratorStateUtils.mergeConfigurationGroups( + undefined as unknown as Configurator.Group[], + incomingGroups + ) + ).toBe(incomingGroups); + }); + + it('should keep attributes of previously loaded groups when incoming group attributes are undefined', () => { + const existingAttribute: Configurator.Attribute = { name: 'attr1' }; + const existingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('group1'), + attributes: [existingAttribute], + }, + ]; + const incomingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('group1'), + attributes: undefined, + }, + ]; + + const mergedGroups = ConfiguratorStateUtils.mergeConfigurationGroups( + existingGroups, + incomingGroups + ); + + expect(mergedGroups[0].attributes).toEqual([existingAttribute]); + }); + + it('should prefer incoming attributes when incoming group has attributes', () => { + const incomingAttribute: Configurator.Attribute = { + name: 'incomingAttr', + }; + const existingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('group1'), + attributes: [{ name: 'existingAttr' }], + }, + ]; + const incomingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('group1'), + attributes: [incomingAttribute], + }, + ]; + + const mergedGroups = ConfiguratorStateUtils.mergeConfigurationGroups( + existingGroups, + incomingGroups + ); + + expect(mergedGroups[0].attributes).toEqual([incomingAttribute]); + }); + + it('should preserve existing-only properties while incoming overrides shared properties', () => { + const existingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('group1'), + name: 'existingName', + description: 'existingDescription', + }, + ]; + const incomingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('group1'), + name: 'incomingName', + }, + ]; + + const mergedGroups = ConfiguratorStateUtils.mergeConfigurationGroups( + existingGroups, + incomingGroups + ); + + expect(mergedGroups[0].name).toBe('incomingName'); + expect(mergedGroups[0].description).toBe('existingDescription'); + }); + + it('should cope with undefined subGroups on both existing and incoming groups', () => { + const existingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('group1'), + subGroups: undefined as unknown as Configurator.Group[], + }, + ]; + const incomingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('group1'), + subGroups: undefined as unknown as Configurator.Group[], + }, + ]; + + const mergedGroups = ConfiguratorStateUtils.mergeConfigurationGroups( + existingGroups, + incomingGroups + ); + + expect(mergedGroups[0].subGroups).toEqual([]); + }); + + it('should merge matching groups and keep non-matching incoming groups', () => { + const existingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('group1'), + attributes: [{ name: 'persistedAttr' }], + }, + ]; + const incomingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('group1'), + attributes: [], + }, + { + ...ConfiguratorTestUtils.createGroup('group2'), + attributes: [{ name: 'newAttr' }], + }, + ]; + + const mergedGroups = ConfiguratorStateUtils.mergeConfigurationGroups( + existingGroups, + incomingGroups + ); + + expect(mergedGroups.length).toBe(2); + expect(mergedGroups[0].attributes?.[0].name).toBe('persistedAttr'); + expect(mergedGroups[1].attributes?.[0].name).toBe('newAttr'); + }); + it('should keep attributes of previously loaded groups when incoming group has no attributes', () => { const existingAttribute: Configurator.Attribute = { name: 'attr1' }; const existingGroups: Configurator.Group[] = [ diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts index 0dba43f34c0..71b855cbefb 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.spec.ts @@ -594,8 +594,8 @@ describe('CpqConfiguratorNormalizer', () => { expect(attribute.dataType).toBe(configuratorAttributeDataType); const values = attribute.values; - expect(values?.length).toBe(3); - expect(values?.[0].valueCode).toBe(Configurator.RetractValueCode); + expect(values?.length).toBe(2); + expect(values?.[0].valueCode).toBe(cpqValuePavId.toString()); }); it('should convert attributes with values - with many sysId', () => { @@ -632,7 +632,7 @@ describe('CpqConfiguratorNormalizer', () => { }); const values = attribute.values; - expect(values?.length).toBe(3); + expect(values?.length).toBe(2); }); it('should convert attributes with values - with only 1 sysId', () => { @@ -672,7 +672,7 @@ describe('CpqConfiguratorNormalizer', () => { expect(attribute.dataType).toBe(configuratorAttributeDataType); const values = attribute.values; - expect(values?.length).toBe(3); + expect(values?.length).toBe(2); }); it('should convert attributes without values', () => { @@ -1478,10 +1478,10 @@ describe('CpqConfiguratorNormalizer', () => { expectSingleSelectionUiType(Configurator.UiType.DROPDOWN, true); }); - it('should return true for SINGLE_SELECTION_IMAGE ui type', () => { + it('should return false for SINGLE_SELECTION_IMAGE ui type', () => { expectSingleSelectionUiType( Configurator.UiType.SINGLE_SELECTION_IMAGE, - true + false ); }); @@ -1509,7 +1509,7 @@ describe('CpqConfiguratorNormalizer', () => { }); }); - describe('isThereSelectedValue', () => { + describe('isNoValueSelected', () => { it('should return true when no value is selected', () => { const cpqAttr: Cpq.Attribute = { pA_ID: 1, @@ -1519,14 +1519,14 @@ describe('CpqConfiguratorNormalizer', () => { { paV_ID: 2, selected: false }, ], }; - expect(cpqConfiguratorNormalizer['isThereSelectedValue'](cpqAttr)).toBe( + expect(cpqConfiguratorNormalizer['isNoValueSelected'](cpqAttr)).toBe( true ); }); it('should return true when values are undefined', () => { const cpqAttr: Cpq.Attribute = { pA_ID: 1, stdAttrCode: 2 }; - expect(cpqConfiguratorNormalizer['isThereSelectedValue'](cpqAttr)).toBe( + expect(cpqConfiguratorNormalizer['isNoValueSelected'](cpqAttr)).toBe( true ); }); @@ -1540,13 +1540,13 @@ describe('CpqConfiguratorNormalizer', () => { { paV_ID: 2, selected: true }, ], }; - expect(cpqConfiguratorNormalizer['isThereSelectedValue'](cpqAttr)).toBe( + expect(cpqConfiguratorNormalizer['isNoValueSelected'](cpqAttr)).toBe( false ); }); }); - describe('isThereAnyRetractValue', () => { + describe('hasRetractValue', () => { it('should return true when a value with paV_ID 0 (retract) is present', () => { const cpqAttr: Cpq.Attribute = { pA_ID: 1, @@ -1556,9 +1556,7 @@ describe('CpqConfiguratorNormalizer', () => { { paV_ID: 1, selected: true }, ], }; - expect(cpqConfiguratorNormalizer['isThereAnyRetractValue'](cpqAttr)).toBe( - true - ); + expect(cpqConfiguratorNormalizer['hasRetractValue'](cpqAttr)).toBe(true); }); it('should return false when no value with paV_ID 0 is present', () => { @@ -1570,16 +1568,12 @@ describe('CpqConfiguratorNormalizer', () => { { paV_ID: 2, selected: true }, ], }; - expect(cpqConfiguratorNormalizer['isThereAnyRetractValue'](cpqAttr)).toBe( - false - ); + expect(cpqConfiguratorNormalizer['hasRetractValue'](cpqAttr)).toBe(false); }); it('should return false when values are undefined', () => { const cpqAttr: Cpq.Attribute = { pA_ID: 1, stdAttrCode: 2 }; - expect(cpqConfiguratorNormalizer['isThereAnyRetractValue'](cpqAttr)).toBe( - false - ); + expect(cpqConfiguratorNormalizer['hasRetractValue'](cpqAttr)).toBe(false); }); }); @@ -1651,15 +1645,47 @@ describe('CpqConfiguratorNormalizer', () => { }); describe('addRetractValue', () => { - it('should add a retract value for RADIOBUTTON attributes', () => { + it('should add a retract value for not required single selection ui types', () => { + const sourceAttribute: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + required: false, + values: [{ paV_ID: 1, selected: false }], + }; + [ + Configurator.UiType.RADIOBUTTON, + Configurator.UiType.DROPDOWN, + Configurator.UiType.DROPDOWN_PRODUCT, + Configurator.UiType.RADIOBUTTON_PRODUCT, + ].forEach((uiType) => { + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + required: false, + uiType, + }; + const values: Configurator.Value[] = []; + cpqConfiguratorNormalizer['addRetractValue']( + sourceAttribute, + attribute, + values + ); + expect(values.length).toBe(1); + expect(values[0].valueCode).toBe(Configurator.RetractValueCode); + expect(values[0].selected).toBe(true); + }); + }); + + it('should not add a retract value for not required SINGLE_SELECTION_IMAGE attributes', () => { const sourceAttribute: Cpq.Attribute = { pA_ID: 1, stdAttrCode: 2, + required: false, values: [{ paV_ID: 1, selected: false }], }; const attribute: Configurator.Attribute = { name: 'ATTRIBUTE_NAME', - uiType: Configurator.UiType.RADIOBUTTON, + required: false, + uiType: Configurator.UiType.SINGLE_SELECTION_IMAGE, }; const values: Configurator.Value[] = []; cpqConfiguratorNormalizer['addRetractValue']( @@ -1667,23 +1693,23 @@ describe('CpqConfiguratorNormalizer', () => { attribute, values ); - expect(values.length).toBe(1); - expect(values[0].valueCode).toBe(Configurator.RetractValueCode); - expect(values[0].selected).toBe(true); + expect(values.length).toBe(0); }); - it('should add a retract value for DROPDOWN and SINGLE_SELECTION_IMAGE attributes', () => { + it('should add a retract value for required drop-down ui types when no value is selected', () => { const sourceAttribute: Cpq.Attribute = { pA_ID: 1, stdAttrCode: 2, - values: [{ paV_ID: 1, selected: true }], + required: true, + values: [{ paV_ID: 1, selected: false }], }; [ Configurator.UiType.DROPDOWN, - Configurator.UiType.SINGLE_SELECTION_IMAGE, + Configurator.UiType.DROPDOWN_PRODUCT, ].forEach((uiType) => { const attribute: Configurator.Attribute = { name: 'ATTRIBUTE_NAME', + required: true, uiType, }; const values: Configurator.Value[] = []; @@ -1693,10 +1719,53 @@ describe('CpqConfiguratorNormalizer', () => { values ); expect(values.length).toBe(1); - expect(values[0].selected).toBe(false); + expect(values[0].valueCode).toBe(Configurator.RetractValueCode); + expect(values[0].selected).toBe(true); }); }); + it('should not add a retract value for required drop-down ui types when a value is selected', () => { + const sourceAttribute: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + required: true, + values: [{ paV_ID: 1, selected: true }], + }; + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + required: true, + uiType: Configurator.UiType.DROPDOWN, + }; + const values: Configurator.Value[] = []; + cpqConfiguratorNormalizer['addRetractValue']( + sourceAttribute, + attribute, + values + ); + expect(values.length).toBe(0); + }); + + it('should not add a retract value for required RADIOBUTTON attributes', () => { + const sourceAttribute: Cpq.Attribute = { + pA_ID: 1, + stdAttrCode: 2, + required: true, + values: [{ paV_ID: 1, selected: false }], + }; + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + required: true, + uiType: Configurator.UiType.RADIOBUTTON, + }; + const values: Configurator.Value[] = []; + cpqConfiguratorNormalizer['addRetractValue']( + sourceAttribute, + attribute, + values + ); + expect(values.length).toBe(0); + }); + it('should not add a retract value for READ_ONLY attributes', () => { const sourceAttribute: Cpq.Attribute = { pA_ID: 1, diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts index f28c127b6f7..35f68b798b9 100644 --- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts +++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer.ts @@ -154,11 +154,11 @@ export class CpqConfiguratorNormalizer return attribute.uiType === Configurator.UiType.READ_ONLY; } - protected isThereAnyRetractValue(sourceAttribute: Cpq.Attribute): boolean { + protected hasRetractValue(sourceAttribute: Cpq.Attribute): boolean { return sourceAttribute.values?.some((value) => value.paV_ID === 0) ?? false; } - protected isThereSelectedValue(sourceAttribute: Cpq.Attribute): boolean { + protected isNoValueSelected(sourceAttribute: Cpq.Attribute): boolean { return !sourceAttribute.values?.filter((value) => value.selected).length; } @@ -189,12 +189,43 @@ export class CpqConfiguratorNormalizer return ( attribute.uiType === Configurator.UiType.RADIOBUTTON || attribute.uiType === Configurator.UiType.DROPDOWN || - attribute.uiType === Configurator.UiType.SINGLE_SELECTION_IMAGE || attribute.uiType === Configurator.UiType.DROPDOWN_PRODUCT || attribute.uiType === Configurator.UiType.RADIOBUTTON_PRODUCT ); } + protected isDropDownUiType(attribute: Configurator.Attribute): boolean { + return ( + attribute.uiType === Configurator.UiType.DROPDOWN || + attribute.uiType === Configurator.UiType.DROPDOWN_PRODUCT + ); + } + + /** + * Determines whether a retract value needs to be added for the given attribute. + * A retract value is added when either: + * 1. the attribute is not required and is of a single selection ui type + * (`RADIOBUTTON`, `DROPDOWN`, `DROPDOWN_PRODUCT` or `RADIOBUTTON_PRODUCT`), or + * 2. the attribute is required, is of a drop-down ui type + * (`DROPDOWN` or `DROPDOWN_PRODUCT`) and no value is selected. + * + * @param sourceAttribute - source CPQ attribute + * @param attribute - converted attribute + * @returns `true` - if a retract value needs to be added + */ + protected isRetractValueNeeded( + sourceAttribute: Cpq.Attribute, + attribute: Configurator.Attribute + ): boolean { + if (attribute.required) { + return ( + this.isDropDownUiType(attribute) && + this.isNoValueSelected(sourceAttribute) + ); + } + return this.isSingleSelectionUiType(attribute); + } + protected addRetractValue( sourceAttribute: Cpq.Attribute, attribute: Configurator.Attribute, @@ -202,12 +233,12 @@ export class CpqConfiguratorNormalizer ) { if ( !this.isUITypeReadOnly(attribute) && - !this.isThereAnyRetractValue(sourceAttribute) && - this.isSingleSelectionUiType(attribute) + !this.hasRetractValue(sourceAttribute) && + this.isRetractValueNeeded(sourceAttribute, attribute) ) { const value: Configurator.Value = { valueCode: Configurator.RetractValueCode, - selected: this.isThereSelectedValue(sourceAttribute), + selected: this.isNoValueSelected(sourceAttribute), }; this.setRetractValueDisplay(attribute, value); values.push(value); From 359db025183ef503c2f2e685c5b62bfb201e25ff Mon Sep 17 00:00:00 2001 From: Larisa Staroverova Date: Thu, 2 Jul 2026 10:00:28 +0200 Subject: [PATCH 11/11] CXSPA-13470: Review feedback II --- .../core/state/configurator-state-utils.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts index 7aacd718a39..def84c06314 100644 --- a/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts +++ b/feature-libs/product-configurator/rulebased/core/state/configurator-state-utils.ts @@ -7,6 +7,29 @@ import { Configurator } from '../model/configurator.model'; export class ConfiguratorStateUtils { + /** + * Merges the groups already held in the store (`existingGroups`) with the + * groups from a server response (`incomingGroups`), producing a fresh group + * tree in which the incoming data is authoritative. + * + * The merge exists because backends differ in how much they return per call: + * - Variant Configuration (VC) and CPQ V1 return attributes for only one + * group at a time (the requested / selected group). The other groups still + * appear in the response but without attributes, so merging preserves the + * attributes that were loaded for them earlier instead of wiping them from + * the store. + * - CPQ V2 returns attributes for all groups in every response. Here the + * incoming tree is already complete, so the incoming data always wins and + * the merge effectively behaves like a replacement. + * + * For a matching group the incoming attributes win when present; an empty or + * undefined incoming attribute list is treated as "not part of this response" + * (VC / CPQ V1), so the existing attributes are kept. + * + * @param existingGroups - groups currently in the store + * @param incomingGroups - groups from the latest server response + * @returns the merged group tree + */ static mergeConfigurationGroups( existingGroups: Configurator.Group[], incomingGroups: Configurator.Group[]