Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2c2a8b9
feat: CPQ configurator process backed by CPQ 2.0 API (CXSPA-12242)
Larisa-Staroverova May 28, 2026
ef6e3ad
Merge branch 'develop' into feature/support_cpq_v2
Larisa-Staroverova May 28, 2026
faaaf37
feat: Refactoring I
Larisa-Staroverova May 28, 2026
26991a7
feat: Refactoring II
Larisa-Staroverova May 28, 2026
6f4434a
feat: Refactoring III
Larisa-Staroverova May 29, 2026
f217915
CXSPA-12242: Make sonar happy
Larisa-Staroverova May 29, 2026
8219fbe
Merge branch 'develop' into feature/support_cpq_v2
Larisa-Staroverova May 29, 2026
7ce9fb6
CXSPA-13470: Refactoring I
Larisa-Staroverova Jun 9, 2026
06b643e
Merge branch 'develop' into feature/support_cpq_v2
Larisa-Staroverova Jun 9, 2026
d45616b
Merge branch 'develop' into feature/support_cpq_v2
Larisa-Staroverova Jun 12, 2026
fa646e9
Merge branch 'develop' into feature/support_cpq_v2
Larisa-Staroverova Jun 29, 2026
2e732ff
CXSPA-13470: Enable retract option for CPQV2
Larisa-Staroverova Jun 29, 2026
d4aa2dd
CXSPA-13470: Refactoring I
Larisa-Staroverova Jul 1, 2026
1c5ea77
Merge branch 'develop' into feature/support_cpq_v2
Larisa-Staroverova Jul 1, 2026
e29b88a
Update feature-libs/product-configurator/rulebased/core/state/effects…
Larisa-Staroverova Jul 1, 2026
8374700
CXSPA-13470: Refactor retract logic
Larisa-Staroverova Jul 1, 2026
359db02
CXSPA-13470: Review feedback II
Larisa-Staroverova Jul 2, 2026
12f1057
Merge branch 'develop' into feature/support_cpq_v2
Larisa-Staroverova Jul 3, 2026
50c074d
Merge branch 'develop' into feature/support_cpq_v2
Larisa-Staroverova Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this method rather confusing. How do we ensure that this doesn't break the VC scenario or the CPQ V1 scenario?
E.g the handling of attributes: In case a group exists already, why are we still taking the attributes from the incoming group? And why does that only happen if attributes lenght is > 0?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a comment to this protected method that hopefully better explains why this method is required.

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 ?? [],
Comment thread
Larisa-Staroverova marked this conversation as resolved.
incomingGroup.subGroups ?? []
Comment thread
Larisa-Staroverova marked this conversation as resolved.
),
};
});
}

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[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading