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..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 @@ -284,4 +284,268 @@ 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 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( + ConfiguratorStateUtils.mergeConfigurationGroups([], incomingGroups) + ).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[] = [ + { + ...ConfiguratorTestUtils.createGroup('group1'), + attributes: [existingAttribute], + }, + ConfiguratorTestUtils.createGroup('group2'), + ]; + const incomingGroups: Configurator.Group[] = [ + { + ...ConfiguratorTestUtils.createGroup('group1'), + attributes: [], + }, + { + ...ConfiguratorTestUtils.createGroup('group2'), + 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..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,84 @@ 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[] + ): 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((g) => g.id === groupId); + if (group) { + return group; + } + + for (const g of groups) { + if (g.subGroups?.length) { + const subgroups = this.findGroupById(g.subGroups, groupId); + if (subgroups) { + return subgroups; + } + } + } + + return undefined; + } + static mergeGroupsWithSupplements( groups: Configurator.Group[], attributeSupplements: Configurator.AttributeSupplement[] 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..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 with group id of conflict group in case conflicts exist but current group is a conflict group', () => { + it('should raise UpdateConfigurationFinalize, UpdatePrices and ChangeGroup when current group is a conflict group', () => { store.dispatch( new ConfiguratorActions.SetCurrentGroup({ entityKey: productConfiguration.owner.key, 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..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 @@ -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) => { - // Group ids of conflict groups (Configurator.GroupType.CONFLICT_GROUP) always start with 'CONFLICT' + const applicableCurrentGroupId = + currentGroupId && + !currentGroupId.startsWith(Configurator.ConflictIdPrefix) + ? currentGroupId + : undefined; + const groupIdFromPayload = + applicableCurrentGroupId ?? this.configuratorBasicEffectService.getFirstGroupWithAttributes( payload, payload.interactionState.isConflictResolutionMode @@ -326,11 +332,11 @@ export class ConfiguratorBasicEffects { this.configuratorGroupUtilsService.getGroupById( payload.groups, groupIdFromPayload - ), - undefined + ) ); + return { - currentGroupId, + applicableCurrentGroupId, groupIdFromPayload, parentGroupFromPayload, }; @@ -351,7 +357,7 @@ export class ConfiguratorBasicEffects { }); const searchVariantsAction = new ConfiguratorActions.SearchVariants(payload); - return container.currentGroupId === + return container.applicableCurrentGroupId === container.groupIdFromPayload ? [ updateFinalizeSuccessAction, @@ -392,7 +398,7 @@ export class ConfiguratorBasicEffects { ) ), take(1), - filter((hasPendingChanges) => hasPendingChanges === false), + filter((hasPendingChanges) => !hasPendingChanges), map( () => new ConfiguratorActions.UpdateConfigurationFinalizeFail( @@ -442,7 +448,7 @@ export class ConfiguratorBasicEffects { ) ), take(1), - filter((hasPendingChanges) => hasPendingChanges === false), + filter((hasPendingChanges) => !hasPendingChanges), switchMap(() => { return this.configuratorCommonsConnector .readConfiguration( 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..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 @@ -230,6 +230,46 @@ describe('Configurator reducer', () => { CONFIGURATION.groups[0].id ); }); + + it('should merge group attributes without clearing previously loaded groups', () => { + const group1: Configurator.Group = { + ...ConfiguratorTestUtils.createGroup('group1'), + attributes: [{ name: 'attr-group1' }], + }; + const group2: Configurator.Group = + ConfiguratorTestUtils.createGroup('group2'); + + const initialConfiguration: Configurator.Configuration = { + ...CONFIGURATION, + groups: [group1, group2], + }; + const stateAfterFirstGroup = StateReduce.configuratorReducer( + undefined, + new ConfiguratorActions.ReadConfigurationSuccess(initialConfiguration) + ); + + const loadedConfiguration: Configurator.Configuration = { + ...CONFIGURATION, + groups: [ + { ...group1, attributes: [] }, + { + ...group2, + attributes: [{ name: 'attr-group2' }], + }, + ], + }; + const stateAfterSecondGroup = StateReduce.configuratorReducer( + stateAfterFirstGroup, + new ConfiguratorActions.ReadConfigurationSuccess(loadedConfiguration) + ); + + expect(stateAfterSecondGroup.groups[0].attributes?.[0].name).toBe( + 'attr-group1' + ); + expect(stateAfterSecondGroup.groups[1].attributes?.[0].name).toBe( + 'attr-group2' + ); + }); }); describe('UpdateConfigurationSuccess action', () => { @@ -490,6 +530,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 +611,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 +716,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 +848,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..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 @@ -129,6 +129,7 @@ const cpqConfiguration: Cpq.Configuration = { currencyISOCode: 'USD', currencySign: '$', responder: { totalPrice: '$3333.33', baseProductPrice: '1000' }, + hasFullConfigurationState: true, }; const ERROR_MSG = 'This is an error message'; @@ -176,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); } } } @@ -223,7 +222,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 +235,31 @@ describe('CpqConfiguratorNormalizer', () => { expect(result.configId).toBe(''); }); + it('should use tab attributes for CPQ version V2 or higher', () => { + const result = cpqConfiguratorNormalizer.convert({ + ...cpqConfiguration, + hasFullConfigurationState: true, + 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, @@ -571,6 +595,7 @@ describe('CpqConfiguratorNormalizer', () => { const values = attribute.values; expect(values?.length).toBe(2); + expect(values?.[0].valueCode).toBe(cpqValuePavId.toString()); }); it('should convert attributes with values - with many sysId', () => { @@ -760,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); @@ -971,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 = { @@ -1235,6 +1268,50 @@ describe('CpqConfiguratorNormalizer', () => { ).toBe(false); }); }); + + describe('getTabAttributes', () => { + it('should return tab attributes when version supports full tab payload', () => { + const source: Cpq.Configuration = { + ...cpqConfiguration, + hasFullConfigurationState: true, + }; + 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, + hasFullConfigurationState: 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, + hasFullConfigurationState: false, + }; + 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']( @@ -1311,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', () => { @@ -1331,6 +1434,431 @@ describe('CpqConfiguratorNormalizer', () => { }); }); + 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['isUITypeReadOnly'](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['isUITypeReadOnly'](attribute)).toBe( + false + ); + }); + }); + + describe('isSingleSelectionUiType', () => { + function expectSingleSelectionUiType( + uiType: Configurator.UiType, + expected: boolean + ): void { + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + uiType, + }; + expect( + 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 false for SINGLE_SELECTION_IMAGE ui type', () => { + expectSingleSelectionUiType( + Configurator.UiType.SINGLE_SELECTION_IMAGE, + false + ); + }); + + 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('isNoValueSelected', () => { + 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['isNoValueSelected'](cpqAttr)).toBe( + true + ); + }); + + it('should return true when values are undefined', () => { + const cpqAttr: Cpq.Attribute = { pA_ID: 1, stdAttrCode: 2 }; + expect(cpqConfiguratorNormalizer['isNoValueSelected'](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['isNoValueSelected'](cpqAttr)).toBe( + false + ); + }); + }); + + describe('hasRetractValue', () => { + 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['hasRetractValue'](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['hasRetractValue'](cpqAttr)).toBe(false); + }); + + it('should return false when values are undefined', () => { + const cpqAttr: Cpq.Attribute = { pA_ID: 1, stdAttrCode: 2 }; + expect(cpqConfiguratorNormalizer['hasRetractValue'](cpqAttr)).toBe(false); + }); + }); + + describe('setRetractValueDisplay', () => { + it('should use drop-down select message for selected DROPDOWN', () => { + const value: Configurator.Value = { valueCode: '0', selected: true }; + 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' + ); + }); + + it('should use no option selected message for non-selected DROPDOWN', () => { + const value: Configurator.Value = { valueCode: '0', selected: false }; + 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' + ); + }); + + it('should use no option selected message for non-dropdown types', () => { + const value: Configurator.Value = { valueCode: '0', selected: true }; + const attribute: Configurator.Attribute = { + name: 'attr_3', + uiType: Configurator.UiType.RADIOBUTTON, + values: [value], + }; + cpqConfiguratorNormalizer['setRetractValueDisplay'](attribute, value); + expect(value.valueDisplay).toBe( + 'configurator.attribute.noOptionSelectedMsg' + ); + }); + }); + + describe('addRetractValue', () => { + 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', + required: false, + uiType: Configurator.UiType.SINGLE_SELECTION_IMAGE, + }; + const values: Configurator.Value[] = []; + cpqConfiguratorNormalizer['addRetractValue']( + sourceAttribute, + attribute, + values + ); + expect(values.length).toBe(0); + }); + + 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, + required: true, + values: [{ paV_ID: 1, selected: false }], + }; + [ + Configurator.UiType.DROPDOWN, + Configurator.UiType.DROPDOWN_PRODUCT, + ].forEach((uiType) => { + const attribute: Configurator.Attribute = { + name: 'ATTRIBUTE_NAME', + required: true, + 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 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, + 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 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, + 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 7f26d94d8c8..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 @@ -27,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 && @@ -46,10 +46,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,27 +71,24 @@ 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[] { - 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( @@ -101,11 +99,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(), @@ -154,6 +150,101 @@ export class CpqConfiguratorNormalizer flatGroupList.push(group); } + protected isUITypeReadOnly(attribute: Configurator.Attribute): boolean { + return attribute.uiType === Configurator.UiType.READ_ONLY; + } + + protected hasRetractValue(sourceAttribute: Cpq.Attribute): boolean { + return sourceAttribute.values?.some((value) => value.paV_ID === 0) ?? false; + } + + protected isNoValueSelected(sourceAttribute: Cpq.Attribute): boolean { + return !sourceAttribute.values?.filter((value) => value.selected).length; + } + + protected setRetractValueDisplay( + attribute: Configurator.Attribute, + value: Configurator.Value + ) { + if ( + (attribute.uiType === Configurator.UiType.DROPDOWN || + attribute.uiType === Configurator.UiType.DROPDOWN_PRODUCT) && + 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 isSingleSelectionUiType( + attribute: Configurator.Attribute + ): boolean { + return ( + attribute.uiType === Configurator.UiType.RADIOBUTTON || + attribute.uiType === Configurator.UiType.DROPDOWN || + 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, + values: Configurator.Value[] + ) { + if ( + !this.isUITypeReadOnly(attribute) && + !this.hasRetractValue(sourceAttribute) && + this.isRetractValueNeeded(sourceAttribute, attribute) + ) { + const value: Configurator.Value = { + valueCode: Configurator.RetractValueCode, + selected: this.isNoValueSelected(sourceAttribute), + }; + this.setRetractValueDisplay(attribute, value); + values.push(value); + } + } + protected convertAttribute( sourceAttribute: Cpq.Attribute, groupId: number, @@ -189,6 +280,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) ); @@ -220,10 +312,8 @@ export class CpqConfiguratorNormalizer protected setSelectedSingleValue(attribute: Configurator.Attribute) { const values = attribute.values; if (values) { - const selectedValues = values - .map((entry) => entry) - .filter((entry) => entry.selected); - if (selectedValues && selectedValues.length === 1) { + const selectedValues = values.filter((entry) => entry.selected); + if (selectedValues?.length === 1) { attribute.selectedSingleValue = selectedValues[0].valueCode; } } @@ -295,13 +385,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 +484,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; } @@ -411,9 +497,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 && @@ -423,4 +509,14 @@ export class CpqConfiguratorNormalizer false ); } + + protected getTabAttributes( + source: Cpq.Configuration, + tab: Cpq.Tab + ): Cpq.Attribute[] { + 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 23dbc71fe3b..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,6 +39,7 @@ export namespace Cpq { tabs?: Tab[]; attributes?: Attribute[]; // attributes of current selected tab configurationId?: string; + hasFullConfigurationState?: boolean; } /** 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 {