diff --git a/core-libs/core/src/features-config/feature-toggles/config/feature-toggles.ts b/core-libs/core/src/features-config/feature-toggles/config/feature-toggles.ts
index 5c4dbef0f99..56e66a9190d 100644
--- a/core-libs/core/src/features-config/feature-toggles/config/feature-toggles.ts
+++ b/core-libs/core/src/features-config/feature-toggles/config/feature-toggles.ts
@@ -707,9 +707,12 @@ export interface FeatureTogglesInterface {
* Nested container products
* are also reflected in the configurator product title (slash-separated
* path) and in the product-title details.
+ * When a CPQ configuration has `hasFullConfigurationState`, root-level
+ * conflict and error messages are taken from the typed `messages` list.
*
* Affects: `ConfiguratorAttributeProductCardComponent`,
- * `ConfiguratorProductTitleComponent`
+ * `ConfiguratorProductTitleComponent`,
+ * `ConfiguratorConflictAndErrorMessagesComponent`
*/
productConfiguratorCPQContainer?: boolean;
diff --git a/feature-libs/product-configurator/common/assets/translations/en/configurator.json b/feature-libs/product-configurator/common/assets/translations/en/configurator.json
index 8b54688a469..10309d0d5c4 100644
--- a/feature-libs/product-configurator/common/assets/translations/en/configurator.json
+++ b/feature-libs/product-configurator/common/assets/translations/en/configurator.json
@@ -49,6 +49,8 @@
"singleSelectRequiredMessage": "Select a value",
"singleSelectAdditionalRequiredMessage": "Select or enter a value",
"multiSelectRequiredMessage": "Select one or more values",
+ "containerRequiredMessage": "Add {{count}} more product",
+ "containerRequiredMessage_other": "Add {{count}} more products",
"wrongNumericFormat": "Wrong format, this numerical attribute should be entered according to pattern {{pattern}}",
"wrongNumericFormatMessage": "Enter the number in the following format: {{pattern}}",
"wrongIntervalFormat": "Enter a value within the indicated boundaries",
@@ -60,7 +62,14 @@
"availableProducts": "Available Products ({{count}})",
"selectAvailableProducts": "Select products",
"searchAvailableProducts": "Search",
- "noAvailableProductsFound": "No results found"
+ "noAvailableProductsFound": "No results found",
+ "containerMinMaxRows": "Select {{minRows}} to {{maxRows}} products",
+ "containerMinRows": "Select at least {{count}} product",
+ "containerMinRows_other": "Select at least {{count}} products",
+ "containerMaxRows": "Select up to {{count}} product",
+ "containerMaxRows_other": "Select up to {{count}} products",
+ "containerExactRows": "Select {{count}} product",
+ "containerExactRows_other": "Select {{count}} products"
},
"button": {
"previous": "Previous",
diff --git a/feature-libs/product-configurator/rulebased/_index.scss b/feature-libs/product-configurator/rulebased/_index.scss
index 7d7ee776c15..618b5ff66ca 100644
--- a/feature-libs/product-configurator/rulebased/_index.scss
+++ b/feature-libs/product-configurator/rulebased/_index.scss
@@ -6,7 +6,8 @@ $configurator-rulebased-components:
cx-required-error-msg, cx-configurator-footer-container,
cx-configurator-footer-container-item, cx-configurator-form-group,
cx-configurator-attribute-input-field, cx-configurator-attribute-radio-button,
- cx-configurator-show-more, cx-configurator-attribute-product-card,
+ cx-configurator-show-more, cx-configurator-message,
+ cx-configurator-attribute-product-card,
cx-configurator-attribute-single-selection-bundle-dropdown,
cx-configurator-attribute-single-selection-bundle,
cx-configurator-attribute-multi-selection-bundle,
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.html b/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.html
index e29d71ff29d..2e70a87161a 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.html
+++ b/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.html
@@ -32,6 +32,23 @@
[productName]="getLabel(expMode, attribute.label, attribute.name)"
[tabIndex]="0"
>
+
-
-
- {{ getRequiredMessageKey() | cxTranslate }}
-
{
component.attribute.required = false;
component.attribute.incomplete = true;
component.attribute.domainOnDemand = false;
+ component.attribute.container = undefined;
component.attribute.uiType = Configurator.UiType.RADIOBUTTON;
component.groupType = Configurator.GroupType.ATTRIBUTE_GROUP;
component.isNavigationToGroupEnabled = true;
@@ -380,6 +382,219 @@ describe('ConfigAttributeHeaderComponent', () => {
'cx-configurator-show-more'
);
});
+
+ it('should not render container row info if container is not present', () => {
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.cx-container-info-msg'
+ );
+ });
+
+ it('should not render container row info if neither minRows nor maxRows is set', () => {
+ component.attribute.container = { rows: [] };
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.cx-container-info-msg'
+ );
+ });
+
+ it('should render container row info when both minRows and maxRows are set', () => {
+ component.attribute.container = { minRows: 2, maxRows: 5, rows: [] };
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-container-info-msg',
+ 'configurator.attribute.containerMinMaxRows maxRows:5 minRows:2'
+ );
+ });
+
+ it('should render container row info when only minRows is set', () => {
+ component.attribute.container = { minRows: 2, rows: [] };
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-container-info-msg',
+ 'configurator.attribute.containerMinRows count:2'
+ );
+ });
+
+ it('should render container row info when only maxRows is set', () => {
+ component.attribute.container = { maxRows: 5, rows: [] };
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-container-info-msg',
+ 'configurator.attribute.containerMaxRows count:5'
+ );
+ });
+
+ it('should render max-only info when minRows is 0', () => {
+ component.attribute.container = { minRows: 0, maxRows: 10, rows: [] };
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-container-info-msg',
+ 'configurator.attribute.containerMaxRows count:10'
+ );
+ });
+
+ it('should render exact-count info when minRows equals maxRows', () => {
+ component.attribute.container = { minRows: 1, maxRows: 1, rows: [] };
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-container-info-msg',
+ 'configurator.attribute.containerExactRows count:1'
+ );
+ });
+
+ it('should not render container row info if minRows is 0 and maxRows is not set', () => {
+ component.attribute.container = { minRows: 0, rows: [] };
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.cx-container-info-msg'
+ );
+ });
+
+ it('should not render error messages if container is not present', () => {
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.cx-error-msg'
+ );
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.cx-warning-msg'
+ );
+ });
+
+ it('should not render messages if the container message list is empty', () => {
+ component.attribute.container = { rows: [], messages: [] };
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.cx-error-msg'
+ );
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.cx-warning-msg'
+ );
+ });
+
+ it('should render a warning message', () => {
+ component.attribute.container = {
+ rows: [],
+ messages: [
+ {
+ message: 'Too many units',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ };
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-msg',
+ 'Too many units'
+ );
+ });
+
+ it('should render warning messages after the required message', () => {
+ component.attribute.required = true;
+ component.attribute.uiType = Configurator.UiType.RADIOBUTTON;
+ component.attribute.container = {
+ rows: [],
+ messages: [
+ {
+ message: 'Too many units',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ };
+ component.ngOnInit();
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-required-error-msg',
+ 'configurator.attribute.singleSelectRequiredMessage'
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-msg',
+ 'Too many units'
+ );
+ });
+
+ it('should render multiple warning messages', () => {
+ component.attribute.container = {
+ rows: [],
+ messages: [
+ {
+ message: 'Too many units',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ {
+ message: 'Invalid selection',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ };
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectNumberOfElements(
+ expect,
+ htmlElem,
+ '.cx-warning-msg',
+ 2
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-msg',
+ 'Too many units'
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-msg',
+ 'Invalid selection',
+ 1
+ );
+ });
+
+ it('should render info messages for info severity', () => {
+ component.attribute.container = {
+ rows: [],
+ messages: [
+ {
+ message: 'Check quantity',
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ ],
+ };
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-info-msg',
+ 'Check quantity'
+ );
+ });
});
describe('getRequiredMessageKey', () => {
@@ -459,6 +674,21 @@ describe('ConfigAttributeHeaderComponent', () => {
'singleSelectAdditionalRequiredMessage'
);
});
+
+ it('should return a container message with remaining products as count', () => {
+ component.attribute.uiType = Configurator.UiType.CONTAINER;
+ component.attribute.container = {
+ minRows: 4,
+ rows: [
+ { id: '1', selected: true },
+ { id: '2', selected: true },
+ ],
+ };
+ expect(component.getRequiredMessageKey()).toEqual({
+ key: 'configurator.attribute.containerRequiredMessage',
+ params: { count: 2 },
+ });
+ });
});
describe('Required message at the attribute level', () => {
@@ -524,6 +754,25 @@ describe('ConfigAttributeHeaderComponent', () => {
'.cx-required-error-msg'
);
});
+
+ it('should render container required message with remaining products as count', () => {
+ component.attribute.uiType = Configurator.UiType.CONTAINER;
+ component.attribute.container = {
+ minRows: 4,
+ rows: [
+ { id: '1', selected: true },
+ { id: '2', selected: true },
+ ],
+ };
+ component.showRequiredMessageForDomainAttribute$ = of(true);
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-required-error-msg',
+ 'configurator.attribute.containerRequiredMessage count:2'
+ );
+ });
});
describe('Conflict text in a conflict group and in configuration', () => {
@@ -978,6 +1227,28 @@ describe('ConfigAttributeHeaderComponent', () => {
'configurator.attribute.singleSelectRequiredMessage'
);
});
+
+ it("should contain div element with 'aria-label' attribute for a container warning message", () => {
+ component.attribute.container = {
+ rows: [],
+ messages: [
+ {
+ message: 'Too many units',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ };
+ fixture.detectChanges();
+ CommonConfiguratorTestUtilsService.expectElementContainsA11y(
+ expect,
+ htmlElem,
+ 'div',
+ 'cx-warning-msg',
+ undefined,
+ 'aria-label',
+ 'Too many units'
+ );
+ });
});
describe('Navigate to corresponding group', () => {
@@ -1154,6 +1425,77 @@ describe('ConfigAttributeHeaderComponent', () => {
});
});
+ describe('messages', () => {
+ it('should return empty arrays if container is not present', () => {
+ expect(component.getContainerMessages()).toEqual({
+ infoMessages: [],
+ errorMessages: [],
+ warningMessages: [],
+ });
+ });
+
+ it('should return empty arrays if list of messages is undefined', () => {
+ component.attribute.container = { rows: [] };
+ expect(component.getContainerMessages()).toEqual({
+ infoMessages: [],
+ errorMessages: [],
+ warningMessages: [],
+ });
+ });
+
+ it('should split messages by severity', () => {
+ component.attribute.container = {
+ rows: [],
+ messages: [
+ {
+ message: 'Too many units',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ {
+ message: 'Check quantity',
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ ],
+ };
+ expect(component.getContainerMessages()).toEqual({
+ infoMessages: ['Check quantity'],
+ warningMessages: ['Too many units'],
+ errorMessages: [],
+ });
+ });
+
+ it('should pass error, warning, and info data to the message component', () => {
+ component.attribute.container = {
+ rows: [],
+ messages: [
+ {
+ message: 'Too many units',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ {
+ message: 'Check quantity',
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ ],
+ };
+
+ const groups = component.getMessageGroups();
+ const warnings = groups.find(
+ (group) => group.uiKeyPrefix === 'warning-msg'
+ );
+ const info = groups.find((group) => group.uiKeyPrefix === 'info-msg');
+
+ expect(warnings?.messages).toEqual(['Too many units']);
+ expect(warnings?.messageClass).toBe('cx-warning-msg');
+ expect(warnings?.showIcon).toBe(true);
+ expect(warnings?.uiKeyPrefix).toBe('warning-msg');
+ expect(info?.messages).toEqual(['Check quantity']);
+ expect(info?.messageClass).toBe('cx-info-msg');
+ expect(info?.showIcon).toBe(false);
+ expect(info?.uiKeyPrefix).toBe('info-msg');
+ });
+ });
+
describe('isAttributeWithoutErrorMsg', () => {
it('should return `false` because attribute UI type is `Configurator.UiType.NOT_IMPLEMENTED`', () => {
component.attribute.uiType = Configurator.UiType.NOT_IMPLEMENTED;
@@ -1238,4 +1580,181 @@ describe('ConfigAttributeHeaderComponent', () => {
expect(component['needsRequiredAttributeErrorMsg']()).toBe(true);
});
});
+
+ describe('isMultiSelection', () => {
+ it('returns true for CHECKBOXLIST', () => {
+ component.attribute.uiType = Configurator.UiType.CHECKBOXLIST;
+ expect(component['isMultiSelection']).toBe(true);
+ });
+
+ it('returns false for RADIOBUTTON', () => {
+ component.attribute.uiType = Configurator.UiType.RADIOBUTTON;
+ expect(component['isMultiSelection']).toBe(false);
+ });
+ });
+
+ describe('isContainerSelection', () => {
+ it('returns true for CONTAINER ui type', () => {
+ component.attribute.uiType = Configurator.UiType.CONTAINER;
+ expect(component['isContainerSelection']()).toBe(true);
+ });
+
+ it('returns false for non-container ui types', () => {
+ component.attribute.uiType = Configurator.UiType.RADIOBUTTON;
+ expect(component['isContainerSelection']()).toBe(false);
+ });
+ });
+
+ describe('getMessageGroups', () => {
+ it('prepends required container message when showRequiredMessage is true', () => {
+ component.attribute.uiType = Configurator.UiType.CONTAINER;
+ component.attribute.container = {
+ minRows: 3,
+ rows: [{ id: '1', selected: true }],
+ };
+
+ const groups = component.getMessageGroups(true);
+
+ expect(groups.map((group) => group.uiKeyPrefix)).toContain(
+ 'required-msg'
+ );
+ });
+
+ it('prepends domain required message for non-container attributes', () => {
+ component.attribute.uiType = Configurator.UiType.RADIOBUTTON;
+ component.attribute.required = true;
+
+ const groups = component.getMessageGroups(true);
+
+ expect(groups[0].uiKeyPrefix).toBe('required-msg');
+ expect(groups[0].messages[0]).toEqual({
+ key: 'configurator.attribute.singleSelectRequiredMessage',
+ });
+ });
+
+ it('does not prepend required message when showRequiredMessage is false', () => {
+ component.attribute.uiType = Configurator.UiType.RADIOBUTTON;
+ component.attribute.required = true;
+
+ const groups = component.getMessageGroups(false);
+
+ expect(groups.some((group) => group.uiKeyPrefix === 'required-msg')).toBe(
+ false
+ );
+ });
+
+ it('keeps a translatable required message unchanged', () => {
+ component.attribute.uiType = Configurator.UiType.RADIOBUTTON;
+ component.attribute.required = true;
+ const translatable = {
+ key: 'configurator.attribute.containerRequiredMessage',
+ params: { count: 2 },
+ };
+ spyOn(component, 'getRequiredMessageKey').and.returnValue(translatable);
+
+ const groups = component.getMessageGroups(true);
+
+ expect(groups[0].messages[0]).toEqual(translatable);
+ });
+
+ it('does not prepend a required group when no message key is resolved', () => {
+ component.attribute.uiType = Configurator.UiType.RADIOBUTTON;
+ component.attribute.required = true;
+ spyOn(component, 'getRequiredMessageKey').and.returnValue(undefined);
+
+ const groups = component.getMessageGroups(true);
+
+ expect(groups.some((group) => group.uiKeyPrefix === 'required-msg')).toBe(
+ false
+ );
+ });
+ });
+
+ describe('container message context callbacks', () => {
+ let configuratorMessageService: ConfiguratorMessageService;
+ let enrichSpy: jasmine.Spy;
+
+ beforeEach(() => {
+ configuratorMessageService = TestBed.inject(ConfiguratorMessageService);
+ enrichSpy = spyOn(
+ configuratorMessageService,
+ 'enrichMessagesWithContainerContext'
+ ).and.callThrough();
+ });
+
+ it('wires the required-message callback passed by getContainerMessages', () => {
+ component.attribute.container = {
+ minRows: 4,
+ rows: [{ id: '1', selected: true }],
+ };
+
+ component.getContainerMessages();
+
+ const context = enrichSpy.calls.mostRecent().args[1];
+ expect(
+ context.getContainerRequiredMessageKey(4, [{ id: '1', selected: true }])
+ ).toEqual({
+ key: 'configurator.attribute.containerRequiredMessage',
+ params: { count: 3 },
+ });
+ });
+
+ it('wires the row-info callback passed by getMessageGroups for containers', () => {
+ component.attribute.uiType = Configurator.UiType.CONTAINER;
+ component.attribute.container = { minRows: 2, maxRows: 5, rows: [] };
+
+ component.getMessageGroups(true);
+
+ const context = enrichSpy.calls.mostRecent().args[1];
+ expect(context.getContainerRowInfoKey(2, 5)).toEqual({
+ key: 'configurator.attribute.containerMinMaxRows',
+ params: { minRows: 2, maxRows: 5 },
+ });
+ });
+ });
+
+ describe('logError', () => {
+ beforeEach(() => {
+ // the global beforeEach replaces logError with a no-op, restore the real one
+ delete (component as unknown as Record)['logError'];
+ });
+
+ it('logs the given text via the logger service', () => {
+ const logger = component['logger'];
+ spyOn(logger, 'error');
+
+ component['logError']('Attribute was not found in any conflict group.');
+
+ expect(logger.error).toHaveBeenCalledWith(
+ 'Attribute was not found in any conflict group.'
+ );
+ });
+ });
+
+ describe('ngOnInit', () => {
+ it('emits true when group is visited and attribute needs required message', (done) => {
+ isCartEntryOrGroupVisited = true;
+ component.attribute.required = true;
+ component.attribute.incomplete = true;
+ component.attribute.uiType = Configurator.UiType.RADIOBUTTON;
+ component.ngOnInit();
+
+ component.showRequiredMessageForDomainAttribute$.subscribe((show) => {
+ expect(show).toBe(true);
+ done();
+ });
+ });
+
+ it('emits false when group has not been visited', (done) => {
+ isCartEntryOrGroupVisited = false;
+ component.attribute.required = true;
+ component.attribute.incomplete = true;
+ component.ngOnInit();
+
+ component.showRequiredMessageForDomainAttribute$.subscribe((show) => {
+ expect(show).toBe(false);
+ done();
+ });
+ });
+ });
});
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.ts
index 9dbaf8a22bb..8d31a544253 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.ts
+++ b/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { AsyncPipe, NgIf } from '@angular/common';
+import { AsyncPipe, NgFor, NgIf } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@@ -12,16 +12,27 @@ import {
isDevMode,
OnInit,
} from '@angular/core';
-import { Config, LoggerService, TranslatePipe } from '@spartacus/core';
+import {
+ Config,
+ LoggerService,
+ Translatable,
+ TranslatePipe,
+} from '@spartacus/core';
import { CommonConfigurator } from '@spartacus/product-configurator/common';
import { ICON_TYPE, IconComponent } from '@spartacus/storefront';
import { Observable } from 'rxjs';
import { delay, filter, map, switchMap, take } from 'rxjs/operators';
import { ConfiguratorCommonsService } from '../../../core/facade/configurator-commons.service';
import { ConfiguratorGroupsService } from '../../../core/facade/configurator-groups.service';
+import {
+ ConfiguratorMessageGroup,
+ ConfiguratorMessageService,
+ ConfiguratorMessagesView,
+} from '../../service/configurator-message.service';
import { Configurator } from '../../../core/model/configurator.model';
import { ConfiguratorUISettingsConfig } from '../../config/configurator-ui-settings.config';
import { ConfiguratorStorefrontUtilsService } from '../../service/configurator-storefront-utils.service';
+import { ConfiguratorMessageComponent } from '../../message/configurator-message.component';
import { ConfiguratorShowMoreComponent } from '../../show-more/configurator-show-more.component';
import { ConfiguratorAttributeCompositionContext } from '../composition/configurator-attribute-composition.model';
import { ConfiguratorShowOptionsComponent } from '../show-options/configurator-show-options.component';
@@ -33,9 +44,11 @@ import { ConfiguratorAttributeBaseComponent } from '../types/base/configurator-a
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
NgIf,
+ NgFor,
IconComponent,
ConfiguratorShowOptionsComponent,
ConfiguratorShowMoreComponent,
+ ConfiguratorMessageComponent,
AsyncPipe,
TranslatePipe,
],
@@ -56,6 +69,7 @@ export class ConfiguratorAttributeHeaderComponent
protected logger = inject(LoggerService);
protected config = inject(Config);
+ protected configuratorMessageService = inject(ConfiguratorMessageService);
constructor(
protected configUtils: ConfiguratorStorefrontUtilsService,
@@ -87,10 +101,18 @@ export class ConfiguratorAttributeHeaderComponent
/**
* Get message key for the required message. Is different for multi- and single selection values
- * @return {string} - required message key
+ * and for container attributes. Container messages include `count` for the remaining
+ * products needed to meet `minRows`, so i18n can pick singular vs plural.
+ *
+ * @return required message key, or a translatable with params for containers
*/
- getRequiredMessageKey(): string {
- if (this.isSingleSelection()) {
+ getRequiredMessageKey(): string | Translatable | undefined {
+ if (this.isContainerSelection()) {
+ return this.getContainerRequiredMessageKey(
+ this.attribute.container?.minRows,
+ this.attribute.container?.rows
+ );
+ } else if (this.isSingleSelection()) {
return this.isWithAdditionalValues(this.attribute)
? 'configurator.attribute.singleSelectAdditionalRequiredMessage'
: 'configurator.attribute.singleSelectRequiredMessage';
@@ -112,6 +134,10 @@ export class ConfiguratorAttributeHeaderComponent
return false;
}
+ protected isContainerSelection(): boolean {
+ return this.attribute.uiType === Configurator.UiType.CONTAINER;
+ }
+
protected isSingleSelection(): boolean {
switch (this.attribute.uiType) {
case Configurator.UiType.RADIOBUTTON:
@@ -159,10 +185,7 @@ export class ConfiguratorAttributeHeaderComponent
* @return {boolean} - 'true' if the group type is 'attribute group' otherwise 'false'
*/
isAttributeGroup(): boolean {
- if (Configurator.GroupType.ATTRIBUTE_GROUP === this.groupType) {
- return true;
- }
- return false;
+ return Configurator.GroupType.ATTRIBUTE_GROUP === this.groupType;
}
/**
@@ -327,4 +350,87 @@ export class ConfiguratorAttributeHeaderComponent
?.attributeDescriptionLength ?? 100
);
}
+ /**
+ * Container messages split into severity buckets.
+ *
+ * @returns Messages grouped by severity
+ */
+ getContainerMessages(): ConfiguratorMessagesView {
+ return this.configuratorMessageService.enrichMessagesWithContainerContext(
+ this.configuratorMessageService.splitMessagesBySeverity(
+ this.attribute.container?.messages
+ ),
+ {
+ minRows: this.attribute.container?.minRows,
+ maxRows: this.attribute.container?.maxRows,
+ rows: this.attribute.container?.rows,
+ includeContainerInfo: !!this.attribute.container,
+ includeRequiredError: false,
+ getContainerRowInfoKey: (minRows, maxRows) =>
+ this.getContainerRowInfoKey(minRows, maxRows),
+ getContainerRequiredMessageKey: (minRows, rows) =>
+ this.getContainerRequiredMessageKey(minRows, rows),
+ }
+ );
+ }
+
+ /**
+ * Retrieves info, warning, and error message groups of the bound container.
+ * Container min/max info and required errors are rendered first.
+ *
+ * @param showRequiredMessage - Whether the required message should be shown
+ * @returns - message groups
+ */
+ getMessageGroups(showRequiredMessage = false): ConfiguratorMessageGroup[] {
+ const messages = this.getContainerMessages();
+ const messagesView =
+ showRequiredMessage && this.isContainerSelection()
+ ? this.configuratorMessageService.enrichMessagesWithContainerContext(
+ messages,
+ {
+ minRows: this.attribute.container?.minRows,
+ maxRows: this.attribute.container?.maxRows,
+ rows: this.attribute.container?.rows,
+ includeContainerInfo: false,
+ includeRequiredError: true,
+ getContainerRowInfoKey: (minRows, maxRows) =>
+ this.getContainerRowInfoKey(minRows, maxRows),
+ getContainerRequiredMessageKey: (minRows, rows) =>
+ this.getContainerRequiredMessageKey(minRows, rows),
+ }
+ )
+ : messages;
+
+ const groups =
+ this.configuratorMessageService.prependContainerContextMessageGroups(
+ messagesView,
+ {
+ containerInfoMessageClass: 'cx-container-info-msg',
+ requiredErrorMessageClass: 'cx-required-error-msg',
+ iconTypeError: this.iconTypes.ERROR,
+ containerInfoUiKeyPrefix: 'container-info-msg',
+ requiredErrorUiKeyPrefix: 'required-msg',
+ }
+ );
+
+ if (showRequiredMessage && !this.isContainerSelection()) {
+ const requiredMessage = this.getRequiredMessageKey();
+ if (requiredMessage) {
+ groups.unshift({
+ messages: [
+ typeof requiredMessage === 'string'
+ ? { key: requiredMessage }
+ : requiredMessage,
+ ],
+ messageClass: 'cx-required-error-msg',
+ iconType: this.iconTypes.ERROR,
+ showIcon: true,
+ uiKeyPrefix: 'required-msg',
+ role: 'alert',
+ });
+ }
+ }
+
+ return groups;
+ }
}
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.module.ts b/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.module.ts
index f9438c92e0c..3fb59e87d20 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.module.ts
+++ b/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.module.ts
@@ -10,6 +10,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NgSelectModule } from '@ng-select/ng-select';
import { I18nModule, provideDefaultConfig } from '@spartacus/core';
import { IconModule } from '@spartacus/storefront';
+import { ConfiguratorMessageModule } from '../../message/configurator-message.module';
import { ConfiguratorShowMoreModule } from '../../show-more/configurator-show-more.module';
import { ConfiguratorAttributeCompositionConfig } from '../composition/configurator-attribute-composition.config';
import { ConfiguratorShowOptionsModule } from '../show-options/configurator-show-options.module';
@@ -24,6 +25,7 @@ import { ConfiguratorAttributeHeaderComponent } from './configurator-attribute-h
IconModule,
NgSelectModule,
ConfiguratorShowMoreModule,
+ ConfiguratorMessageModule,
ConfiguratorShowOptionsModule,
ConfiguratorAttributeHeaderComponent,
],
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.html b/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.html
index af4588451a4..1f4f83c92f6 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.html
+++ b/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.html
@@ -1,130 +1,71 @@
-
-
-
-
-
-
-
-
-
- {{ product.name }}
-
-
-
- {{ 'configurator.attribute.id' | cxTranslate }}:
- {{ product.code }}
-
-
-
-
-
+
+
-
-
-
+
+
+
-
-
+
+
+
+
+ {{ product.name }}
+
+
+
+ {{ 'configurator.attribute.id' | cxTranslate }}:
+ {{ product.code }}
+
+
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
+
+
@@ -303,171 +152,452 @@
else deselect
"
>
-
-
-
+
+
+
+
+
+
+ {{ getAriaLabelSingleSelectedNoButton(product) }}
+
-
+
+
+
+
+
+
+
+
+ {{ 'configurator.attribute.deselectionNotPossible' | cxTranslate }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ product.name }}
+
+
+
+ {{ 'configurator.attribute.id' | cxTranslate }}:
+ {{ product.code }}
+
+
+
+
+
+
+
+
+
+
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- {{ getAriaLabelSingleSelectedNoButton(product) }}
-
-
-
-
-
+
+
+
+
+ {{ getAriaLabelSingleSelectedNoButton(product) }}
+
+
+
+
+
+
+
+
+ 0 || showDeselectionNotPossible"
+ >
+
+
+
+
+ {{
+ 'configurator.attribute.deselectionNotPossible' | cxTranslate
+ }}
+
+
+
+
-
-
-
- {{ 'configurator.attribute.deselectionNotPossible' | cxTranslate }}
-
-
-
+
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.spec.ts
index 7668a995deb..c88e21fef47 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.spec.ts
+++ b/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.spec.ts
@@ -26,6 +26,7 @@ import {
} from 'core-libs/core/src/features-config/feature-toggles/testing';
import {
FocusDirective,
+ ICON_TYPE,
ItemCounterComponent,
KeyboardFocusService,
MediaModule,
@@ -35,6 +36,7 @@ import { UrlTestingModule } from 'core-libs/core/src/routing/configurable-routes
import { BehaviorSubject, EMPTY, Observable, of, throwError } from 'rxjs';
import { take } from 'rxjs/operators';
import { CommonConfiguratorTestUtilsService } from '../../../../common/testing/common-configurator-test-utils.service';
+import { ConfiguratorMessageGroup } from '../../service/configurator-message.service';
import { Configurator } from '../../../core/model/configurator.model';
import {
ConfiguratorPriceComponent,
@@ -86,6 +88,12 @@ class MockProductService {
}
}
+class MockConfiguratorStorefrontUtilsService {
+ isCartEntryOrGroupVisited(): Observable
{
+ return of(true);
+ }
+}
+
let focusService: KeyboardFocusService;
@Component({
@@ -200,7 +208,7 @@ describe('ConfiguratorAttributeProductCardComponent', () => {
},
{
provide: ConfiguratorStorefrontUtilsService,
- useValue: {},
+ useClass: MockConfiguratorStorefrontUtilsService,
},
provideMockFeatureToggles({
productConfiguratorConsolidatedButtonDisabling: true,
@@ -257,6 +265,12 @@ describe('ConfiguratorAttributeProductCardComponent', () => {
hideRemoveButton: false,
multiSelect: false,
productBoundValue: value,
+ attribute: {
+ attrCode: 123,
+ label: 'Attribute Label',
+ name: 'Attribute Name',
+ container: { rows: [] },
+ },
singleDropdown: false,
withQuantity: true,
attributeId: 123,
@@ -523,6 +537,11 @@ describe('ConfiguratorAttributeProductCardComponent', () => {
'1111-2222',
'Lorem Ipsum Dolor'
),
+ attribute: {
+ attrCode: 123,
+ label: 'Attribute Label',
+ name: 'Attribute Name',
+ },
singleDropdown: false,
withQuantity: true,
disableAllButtons: true,
@@ -602,6 +621,11 @@ describe('ConfiguratorAttributeProductCardComponent', () => {
'1111-2222',
'Lorem Ipsum Dolor'
),
+ attribute: {
+ attrCode: 123,
+ label: 'Attribute Label',
+ name: 'Attribute Name',
+ },
singleDropdown: false,
withQuantity: true,
attributeId: 123,
@@ -1368,12 +1392,12 @@ describe('ConfiguratorAttributeProductCardComponent', () => {
});
describe('Accessibility', () => {
- it("should contain div element with class name 'cx-product-card' and 'aria-label' attribute that defines an accessible name to label the current element", () => {
+ it("should contain div element with class name 'cx-product-card-container' and 'aria-label' attribute that defines an accessible name to label the current element", () => {
CommonConfiguratorTestUtilsService.expectElementContainsA11y(
expect,
htmlElem,
'div',
- 'cx-product-card',
+ 'cx-product-card-container',
0,
'aria-label',
'configurator.a11y.itemOfAttribute attribute:' +
@@ -1424,7 +1448,8 @@ describe('ConfiguratorAttributeProductCardComponent', () => {
'btn-secondary',
0,
'aria-describedby',
- 'cx-configurator--label--' + component.productCardOptions.attributeName,
+ 'cx-configurator--label--' +
+ component.productCardOptions.attribute.name,
'configurator.button.select'
);
});
@@ -1633,4 +1658,377 @@ describe('ConfiguratorAttributeProductCardComponent', () => {
expect(htmlElem.querySelector('button.btn')).toBeFalsy();
});
});
+
+ describe('container row messages', () => {
+ // Message groups are pre-built by the parent container and passed via
+ // `productCardOptions.messages`. The card only renders what it receives.
+ function errorGroup(messages: string[]): ConfiguratorMessageGroup {
+ return {
+ messages,
+ messageClass: 'cx-error-msg',
+ iconType: ICON_TYPE.ERROR,
+ showIcon: true,
+ uiKeyPrefix: 'error-msg',
+ role: 'alert',
+ };
+ }
+
+ function infoGroup(messages: string[]): ConfiguratorMessageGroup {
+ return {
+ messages,
+ messageClass: 'cx-info-msg',
+ showIcon: false,
+ uiKeyPrefix: 'info-msg',
+ };
+ }
+
+ function containerInfoGroup(messages: string[]): ConfiguratorMessageGroup {
+ return {
+ messages,
+ messageClass: 'cx-container-info-msg',
+ showIcon: false,
+ uiKeyPrefix: 'row-container-info-msg',
+ };
+ }
+
+ function requiredErrorGroup(messages: string[]): ConfiguratorMessageGroup {
+ return {
+ messages,
+ messageClass: 'cx-container-error-msg',
+ iconType: ICON_TYPE.ERROR,
+ showIcon: true,
+ uiKeyPrefix: 'row-required-msg',
+ role: 'alert',
+ };
+ }
+
+ function setMessages(groups: ConfiguratorMessageGroup[]): void {
+ component.productCardOptions.containerRow = {
+ id: 'row-1',
+ productSystemId: 'PRODUCT_CODE',
+ selected: true,
+ };
+ component.productCardOptions.messages = groups;
+ }
+
+ describe('message container visibility', () => {
+ it('hides container when there are no messages and no deselection error', () => {
+ component.productCardOptions.multiSelect = true;
+ setProductBoundValueAttributes(component);
+ setMessages([]);
+ fixture.detectChanges();
+
+ expect(htmlElem.querySelector('.cx-product-card.message')).toBeFalsy();
+ });
+
+ it('shows container when message groups are provided', () => {
+ component.productCardOptions.multiSelect = true;
+ setProductBoundValueAttributes(component);
+ setMessages([errorGroup(['Too many units'])]);
+ fixture.detectChanges();
+
+ expect(htmlElem.querySelector('.cx-product-card.message')).toBeTruthy();
+ });
+
+ it('shows container when deselection error is active', () => {
+ component.productCardOptions.multiSelect = true;
+ setProductBoundValueAttributes(component);
+ setMessages([]);
+ component.showDeselectionNotPossible = true;
+ fixture.detectChanges();
+
+ expect(htmlElem.querySelector('.cx-product-card.message')).toBeTruthy();
+ });
+
+ it('hides container when messages are undefined and no deselection error', () => {
+ component.productCardOptions.multiSelect = true;
+ setProductBoundValueAttributes(component);
+ component.productCardOptions.messages = undefined;
+ fixture.detectChanges();
+
+ expect(htmlElem.querySelector('.cx-product-card.message')).toBeFalsy();
+ });
+ });
+
+ describe('message group rendering', () => {
+ it('renders no message rows when groups are empty', () => {
+ component.productCardOptions.multiSelect = true;
+ setProductBoundValueAttributes(component);
+ setMessages([]);
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.cx-error-msg'
+ );
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.cx-info-msg'
+ );
+ });
+
+ it('renders error group rows with icon', () => {
+ component.productCardOptions.multiSelect = true;
+ setProductBoundValueAttributes(component);
+ setMessages([errorGroup(['Too many units', 'Invalid selection'])]);
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectNumberOfElementsPresent(
+ expect,
+ htmlElem,
+ '.cx-error-msg',
+ 2
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-error-msg',
+ 'Too many units'
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-error-msg',
+ 'Invalid selection',
+ 1
+ );
+ CommonConfiguratorTestUtilsService.expectElementPresent(
+ expect,
+ htmlElem,
+ '.cx-error-msg cx-icon'
+ );
+ });
+
+ it('renders info group rows without icon', () => {
+ component.productCardOptions.multiSelect = true;
+ setProductBoundValueAttributes(component, false);
+ setMessages([infoGroup(['Check quantity', 'Review selection'])]);
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectNumberOfElementsPresent(
+ expect,
+ htmlElem,
+ '.cx-info-msg',
+ 2
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-info-msg',
+ 'Check quantity'
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-info-msg',
+ 'Review selection',
+ 1
+ );
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.cx-info-msg cx-icon'
+ );
+ });
+
+ it('renders one cx-configurator-message per group', () => {
+ component.productCardOptions.multiSelect = true;
+ setProductBoundValueAttributes(component);
+ setMessages([
+ errorGroup(['Too many units']),
+ infoGroup(['Check quantity']),
+ ]);
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectNumberOfElementsPresent(
+ expect,
+ htmlElem,
+ 'cx-configurator-message',
+ 2
+ );
+ });
+
+ it('renders container info, required and engine groups together', () => {
+ component.productCardOptions.multiSelect = true;
+ setProductBoundValueAttributes(component, false);
+ setMessages([
+ containerInfoGroup(['Select at least 2 products']),
+ requiredErrorGroup(['Required selection missing']),
+ infoGroup(['Review selection']),
+ ]);
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectNumberOfElementsPresent(
+ expect,
+ htmlElem,
+ 'cx-configurator-message',
+ 3
+ );
+ CommonConfiguratorTestUtilsService.expectElementPresent(
+ expect,
+ htmlElem,
+ '.cx-container-info-msg'
+ );
+ CommonConfiguratorTestUtilsService.expectElementPresent(
+ expect,
+ htmlElem,
+ '.cx-container-error-msg cx-icon'
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-container-info-msg',
+ 'Select at least 2 products'
+ );
+ });
+
+ it('renders row messages alongside deselection error', () => {
+ component.productCardOptions.multiSelect = true;
+ setProductBoundValueAttributes(component);
+ setMessages([errorGroup(['Too many units'])]);
+ component.showDeselectionNotPossible = true;
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectNumberOfElementsPresent(
+ expect,
+ htmlElem,
+ 'cx-configurator-message',
+ 1
+ );
+ CommonConfiguratorTestUtilsService.expectElementPresent(
+ expect,
+ htmlElem,
+ '.cx-deselection-error-msg'
+ );
+ });
+ });
+
+ describe('when messages input is undefined', () => {
+ it('shows deselection error without row messages', () => {
+ component.productCardOptions.multiSelect = true;
+ setProductBoundValueAttributes(component);
+ component.productCardOptions.messages = undefined;
+ component.showDeselectionNotPossible = true;
+ fixture.detectChanges();
+
+ expect(htmlElem.querySelector('.cx-product-card.message')).toBeTruthy();
+ expect(
+ htmlElem.querySelector('.cx-deselection-error-msg')
+ ).toBeTruthy();
+ });
+ });
+ });
+
+ describe('additional utility methods', () => {
+ it('should extract price formula parameters for single-select', () => {
+ // single select
+ component.productCardOptions.multiSelect = false;
+ const productBoundValue = setProductBoundValueAttributes(
+ component,
+ true,
+ undefined
+ );
+ productBoundValue.valuePrice = {
+ currencyIso: '$',
+ formattedValue: '$5',
+ value: 5,
+ } as any;
+
+ const params = component.extractPriceFormulaParameters();
+ expect(params.price).toBe(productBoundValue.valuePrice);
+ expect(params.isLightedUp).toBe(true);
+ expect((params as any).quantity).toBeUndefined();
+ });
+
+ it('should extract price formula parameters for multi-select', () => {
+ component.productCardOptions.multiSelect = true;
+ const productBoundValue = setProductBoundValueAttributes(
+ component,
+ true,
+ 3
+ );
+ productBoundValue.valuePrice = {
+ currencyIso: '$',
+ formattedValue: '$5',
+ value: 5,
+ } as any;
+ productBoundValue.valuePriceTotal = {
+ currencyIso: '$',
+ formattedValue: '$15',
+ value: 15,
+ } as any;
+
+ const params = component.extractPriceFormulaParameters();
+ expect((params as any).quantity).toBe(3);
+ expect(params.price).toBe(productBoundValue.valuePrice);
+ expect((params as any).priceTotal).toBe(
+ productBoundValue.valuePriceTotal
+ );
+ expect(params.isLightedUp).toBe(true);
+ });
+
+ it('should determine product card selection correctly', () => {
+ // selected and not single dropdown => true
+ setProductBoundValueAttributes(component, true);
+ component.productCardOptions.singleDropdown = false;
+ expect(component.isProductCardSelected()).toBe(true);
+
+ // singleDropdown true => false
+ component.productCardOptions.singleDropdown = true;
+ expect(component.isProductCardSelected()).toBe(false);
+
+ // not selected => false
+ setProductBoundValueAttributes(component, false);
+ component.productCardOptions.singleDropdown = false;
+ expect(component.isProductCardSelected()).toBe(false);
+ });
+
+ it('should not show quantity when withQuantity is undefined', () => {
+ component.productCardOptions.withQuantity = undefined;
+ component.productCardOptions.multiSelect = true;
+ setProductBoundValueAttributes(component);
+
+ expect(component.showQuantity).toBe(false);
+ });
+
+ it('should return no row actions when the card is not in container context', () => {
+ component.productCardOptions.containerRow = undefined;
+
+ expect(component.containerRowActions).toEqual([]);
+ });
+
+ it('should not mark the card as selected when the selection state is undefined', () => {
+ component.productCardOptions.productBoundValue.selected = undefined;
+ component.productCardOptions.singleDropdown = false;
+
+ expect(component.isProductCardSelected()).toBe(false);
+ });
+
+ it('should fall back to a zero initial quantity when no quantity is set', () => {
+ component.productCardOptions.productBoundValue.quantity = undefined;
+
+ expect(component.extractQuantityParameters().initialQuantity).toBe(0);
+ });
+
+ it('should not reset to the initial quantity when hideRemoveButton is undefined', () => {
+ component.productCardOptions.hideRemoveButton = undefined;
+ setProductBoundValueAttributes(component, true, 2);
+
+ expect(
+ component.extractQuantityParameters().resetToInitialQuantityOnZero
+ ).toBe(false);
+ });
+
+ it('should emit row action and close menu onHandleRowAction', () => {
+ spyOn(component.handleRowAction, 'emit');
+ component.isActionsMenuOpen = true;
+ component.onHandleRowAction(Configurator.ContainerRowAction.DELETE);
+ expect(component.handleRowAction.emit).toHaveBeenCalledWith(
+ Configurator.ContainerRowAction.DELETE
+ );
+ expect(component.isActionsMenuOpen).toBe(false);
+ });
+ });
});
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.ts
index 434d7f5a7ab..71c98e35162 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.ts
+++ b/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.component.ts
@@ -31,10 +31,12 @@ import {
KeyboardFocusService,
MediaComponent,
} from '@spartacus/storefront';
-import { BehaviorSubject, Observable, combineLatest, of } from 'rxjs';
+import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs';
import { catchError, map, take, tap } from 'rxjs/operators';
+import { ConfiguratorMessageGroup } from '../../service/configurator-message.service';
import { Configurator } from '../../../core/model/configurator.model';
import { QuantityUpdateEvent } from '../../form/configurator-form.event';
+import { ConfiguratorMessageComponent } from '../../message/configurator-message.component';
import {
ConfiguratorPriceComponent,
ConfiguratorPriceComponentOptions,
@@ -57,6 +59,7 @@ export interface ConfiguratorAttributeProductCardComponentOptions {
fallbackFocusId?: string;
multiSelect?: boolean;
productBoundValue: Configurator.Value;
+ attribute: Configurator.Attribute;
singleDropdown?: boolean;
withQuantity?: boolean;
/**
@@ -65,8 +68,24 @@ export interface ConfiguratorAttributeProductCardComponentOptions {
* This prevents the user from triggering concurrent requests with potential conflicting content that might cause unexpected behavior.
*/
loading$?: Observable;
+ messages?: ConfiguratorMessageGroup[];
+ /**
+ * @deprecated since 221121.17 - Use `this.getAttributeCode(this.attribute)` instead which will be
+ * used in the components. This property remains for backward
+ * compatibility and will be removed in a future major version.
+ */
attributeId: number;
+ /**
+ * @deprecated since 221121.17 - Use `attribute.label` instead which will be
+ * used in the components. This property remains for backward
+ * compatibility and will be removed in a future major version.
+ */
attributeLabel?: string;
+ /**
+ * @deprecated since 221121.17 - Use `attribute.name` instead which will be
+ * used in the components. This property remains for backward
+ * compatibility and will be removed in a future major version.
+ */
attributeName: string;
itemCount: number;
itemIndex: number;
@@ -85,6 +104,7 @@ export interface ConfiguratorAttributeProductCardComponentOptions {
ConfiguratorShowMoreComponent,
ConfiguratorAttributeQuantityComponent,
ConfiguratorPriceComponent,
+ ConfiguratorMessageComponent,
FocusDirective,
IconComponent,
AsyncPipe,
@@ -137,16 +157,17 @@ export class ConfiguratorAttributeProductCardComponent
this.product$ = this.productService
.get(
- productSystemId ? productSystemId : '',
+ productSystemId || '',
ConfiguratorProductScope.CONFIGURATOR_PRODUCT_CARD
)
.pipe(
map((respProduct) => {
- return respProduct
- ? respProduct
- : this.transformToProductType(
- this.productCardOptions.productBoundValue
- );
+ return (
+ respProduct ??
+ this.transformToProductType(
+ this.productCardOptions.productBoundValue
+ )
+ );
}),
catchError(() =>
of(
@@ -213,13 +234,12 @@ export class ConfiguratorAttributeProductCardComponent
}
get focusConfig(): FocusConfig {
- const focusConfig = {
+ return {
key: this.createFocusId(
- this.productCardOptions.attributeId.toString(),
+ this.getAttributeCode(this.productCardOptions.attribute).toString(),
this.productCardOptions.productBoundValue.valueCode
),
};
- return focusConfig;
}
onHandleSelect(): void {
@@ -257,7 +277,7 @@ export class ConfiguratorAttributeProductCardComponent
/**
* Verifies whether the product card refers to a selected value
- * @return {boolean} - Selected?
+ * @return - Selected?
*/
isProductCardSelected(): boolean {
const isProductCardSelected =
@@ -272,21 +292,20 @@ export class ConfiguratorAttributeProductCardComponent
* Checks if price needs to be displayed. This is the
* case if either value price, quantity or value price total
* are present
- * @return {boolean} - Price display?
+ * @return - Price display?
*/
hasPriceDisplay(): boolean {
const productPrice =
this.productCardOptions.productBoundValue.valuePrice ||
this.productCardOptions.productBoundValue.quantity ||
this.productCardOptions.productBoundValue.valuePriceTotal;
-
- return productPrice ? true : false;
+ return !!productPrice;
}
/**
* Extract corresponding price formula parameters
*
- * @return {ConfiguratorPriceComponentOptions} - New price formula
+ * @return - New price formula
*/
extractPriceFormulaParameters(): ConfiguratorPriceComponentOptions {
if (!this.productCardOptions.multiSelect) {
@@ -306,7 +325,7 @@ export class ConfiguratorAttributeProductCardComponent
/**
* Extract corresponding quantity parameters
*
- * @return {ConfiguratorAttributeQuantityComponentOptions} - New quantity options
+ * @return - New quantity options
*/
extractQuantityParameters(): ConfiguratorAttributeQuantityComponentOptions {
const quantityFromOptions =
@@ -326,13 +345,11 @@ export class ConfiguratorAttributeProductCardComponent
/**
* Verifies whether the value code is defined.
*
- * @param {string} valueCode - Value code
- * @return {boolean} - 'true' if the value code is defined, otherwise 'false'
+ * @param valueCode - Value code
+ * @return - 'true' if the value code is defined, otherwise 'false'
*/
isValueCodeDefined(valueCode: string | null | undefined): boolean {
- return valueCode && valueCode !== Configurator.RetractValueCode
- ? true
- : false;
+ return !!(valueCode && valueCode !== Configurator.RetractValueCode);
}
protected transformToProductType(
@@ -425,7 +442,7 @@ export class ConfiguratorAttributeProductCardComponent
this.translation
.translate('configurator.a11y.itemOfAttributeUnselectedWithPrice', {
item: product.code,
- attribute: this.productCardOptions?.attributeLabel,
+ attribute: this.productCardOptions.attribute.label,
itemIndex: index,
itemCount: this.productCardOptions.itemCount,
price:
@@ -438,7 +455,7 @@ export class ConfiguratorAttributeProductCardComponent
this.translation
.translate('configurator.a11y.itemOfAttributeUnselected', {
item: product.code,
- attribute: this.productCardOptions?.attributeLabel,
+ attribute: this.productCardOptions?.attribute.label,
itemIndex: index,
itemCount: this.productCardOptions.itemCount,
})
@@ -448,7 +465,7 @@ export class ConfiguratorAttributeProductCardComponent
} else {
this.translation
.translate('configurator.a11y.selectNoItemOfAttribute', {
- attribute: this.productCardOptions?.attributeLabel,
+ attribute: this.productCardOptions?.attribute.label,
itemIndex: index,
itemCount: this.productCardOptions.itemCount,
})
@@ -470,7 +487,7 @@ export class ConfiguratorAttributeProductCardComponent
'configurator.a11y.itemOfAttributeSelectedPressToUnselectWithPrice',
{
item: product.code,
- attribute: this.productCardOptions?.attributeLabel,
+ attribute: this.productCardOptions?.attribute.label,
itemIndex: index,
itemCount: this.productCardOptions.itemCount,
price:
@@ -484,7 +501,7 @@ export class ConfiguratorAttributeProductCardComponent
this.translation
.translate('configurator.a11y.itemOfAttributeSelectedPressToUnselect', {
item: product.code,
- attribute: this.productCardOptions?.attributeLabel,
+ attribute: this.productCardOptions?.attribute.label,
itemIndex: index,
itemCount: this.productCardOptions.itemCount,
})
@@ -505,7 +522,7 @@ export class ConfiguratorAttributeProductCardComponent
this.translation
.translate('configurator.a11y.itemOfAttributeSelectedWithPrice', {
item: product.code,
- attribute: this.productCardOptions?.attributeLabel,
+ attribute: this.productCardOptions?.attribute.label,
itemIndex: index,
itemCount: this.productCardOptions.itemCount,
price:
@@ -518,7 +535,7 @@ export class ConfiguratorAttributeProductCardComponent
this.translation
.translate('configurator.a11y.itemOfAttributeSelected', {
item: product.code,
- attribute: this.productCardOptions?.attributeLabel,
+ attribute: this.productCardOptions?.attribute.label,
itemIndex: index,
itemCount: this.productCardOptions.itemCount,
})
@@ -541,7 +558,7 @@ export class ConfiguratorAttributeProductCardComponent
'configurator.a11y.itemOfAttributeSelectedPressToUnselectWithPrice',
{
item: product.code,
- attribute: this.productCardOptions?.attributeLabel,
+ attribute: this.productCardOptions?.attribute.label,
itemIndex: index,
itemCount: this.productCardOptions.itemCount,
price:
@@ -555,7 +572,7 @@ export class ConfiguratorAttributeProductCardComponent
this.translation
.translate('configurator.a11y.itemOfAttributeSelectedPressToUnselect', {
item: product.code,
- attribute: this.productCardOptions?.attributeLabel,
+ attribute: this.productCardOptions?.attribute.label,
itemIndex: index,
itemCount: this.productCardOptions.itemCount,
})
@@ -576,7 +593,7 @@ export class ConfiguratorAttributeProductCardComponent
this.translation
.translate('configurator.a11y.itemOfAttributeUnselectedWithPrice', {
item: product.code,
- attribute: this.productCardOptions?.attributeLabel,
+ attribute: this.productCardOptions?.attribute.label,
itemIndex: index,
itemCount: this.productCardOptions.itemCount,
price:
@@ -589,7 +606,7 @@ export class ConfiguratorAttributeProductCardComponent
this.translation
.translate('configurator.a11y.itemOfAttributeUnselected', {
item: product.code,
- attribute: this.productCardOptions?.attributeLabel,
+ attribute: this.productCardOptions?.attribute.label,
itemIndex: index,
itemCount: this.productCardOptions.itemCount,
})
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.module.ts b/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.module.ts
index 5aba800d0d5..7657cf6b253 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.module.ts
+++ b/feature-libs/product-configurator/rulebased/components/attribute/product-card/configurator-attribute-product-card.module.ts
@@ -14,6 +14,7 @@ import {
KeyboardFocusModule,
MediaModule,
} from '@spartacus/storefront';
+import { ConfiguratorMessageModule } from '../../message/configurator-message.module';
import { ConfiguratorPriceModule } from '../../price/configurator-price.module';
import { ConfiguratorShowMoreModule } from '../../show-more/configurator-show-more.module';
import { ConfiguratorAttributeQuantityModule } from '../quantity/configurator-attribute-quantity.module';
@@ -32,6 +33,7 @@ import { ConfiguratorAttributeProductCardComponent } from './configurator-attrib
ReactiveFormsModule,
MediaModule,
ConfiguratorPriceModule,
+ ConfiguratorMessageModule,
KeyboardFocusModule,
IconModule,
ConfiguratorAttributeProductCardComponent,
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.spec.ts
index 613505eac2b..5ba0efaed9d 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.spec.ts
+++ b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.spec.ts
@@ -161,6 +161,21 @@ describe('ConfiguratorAttributeBaseComponent', () => {
)
).toBe('cx-configurator--radioGroup--attributeId--valueId');
});
+
+ it('falls back to the attribute id when no value is given', () => {
+ expect(
+ classUnderTest.createAttributeValueIdForConfigurator(currentAttribute)
+ ).toBe('cx-configurator--radioGroup--attributeId');
+ });
+
+ it('uses the "not implemented" ui type when the attribute has none', () => {
+ expect(
+ classUnderTest.createAttributeValueIdForConfigurator(
+ attributeIncomplete,
+ 'valueId'
+ )
+ ).toBe('cx-configurator--not_implemented--name--valueId');
+ });
});
describe('getImage', () => {
@@ -427,6 +442,137 @@ describe('ConfiguratorAttributeBaseComponent', () => {
' [+' + value.valuePrice?.formattedValue + ']'
);
});
+
+ it('returns empty string when value is selected even if price is set', () => {
+ const value = ConfiguratorTestUtils.createValue('valueCode', 10, true);
+ expect(classUnderTest['getValuePrice'](value)).toEqual('');
+ });
+ });
+
+ describe('createContainerUiKey', () => {
+ it('returns attribute key when valueId is omitted', () => {
+ expect(classUnderTest.createContainerUiKey('prefix', 'attributeId')).toBe(
+ 'cx-configurator--prefix--attributeId'
+ );
+ });
+
+ it('returns value key when valueId is provided', () => {
+ expect(
+ classUnderTest.createContainerUiKey('prefix', 'attributeId', 'valueId')
+ ).toBe('cx-configurator--prefix--attributeId--valueId');
+ });
+ });
+
+ describe('getContainerRequiredMessageKey', () => {
+ it('returns translatable when remaining count is at least 1', () => {
+ expect(
+ classUnderTest.getContainerRequiredMessageKey(4, [
+ { id: '1', selected: true },
+ ])
+ ).toEqual({
+ key: 'configurator.attribute.containerRequiredMessage',
+ params: { count: 3 },
+ });
+ });
+
+ it('returns undefined when minimum selection is satisfied', () => {
+ expect(
+ classUnderTest.getContainerRequiredMessageKey(2, [
+ { id: '1', selected: true },
+ { id: '2', selected: true },
+ ])
+ ).toBeUndefined();
+ });
+ });
+
+ describe('getSelectedValue', () => {
+ it('returns the selected value from attribute values', () => {
+ currentAttribute.values = [
+ ConfiguratorTestUtils.createValue('123', 10, true),
+ ConfiguratorTestUtils.createValue('456', 15),
+ ];
+ expect(
+ classUnderTest['getSelectedValue'](currentAttribute)?.valueCode
+ ).toBe('123');
+ });
+
+ it('returns undefined when no value is selected', () => {
+ currentAttribute.values = [ConfiguratorTestUtils.createValue('456', 15)];
+ expect(
+ classUnderTest['getSelectedValue'](currentAttribute)
+ ).toBeUndefined();
+ });
+ });
+
+ describe('getAriaLabelGeneric', () => {
+ it('returns empty string when value is undefined', () => {
+ expect(
+ classUnderTest['getAriaLabelGeneric'](currentAttribute, undefined)
+ ).toBe('');
+ });
+
+ it('returns translated label including value and attribute', () => {
+ const value = ConfiguratorTestUtils.createValue('123', 10);
+ value.valueDisplay = 'Red';
+ currentAttribute.label = 'Color';
+ expect(
+ classUnderTest['getAriaLabelGeneric'](currentAttribute, value)
+ ).toContain('Red');
+ });
+
+ it('uses selected-value key when considerSelectionState is true and value is selected', () => {
+ const value = ConfiguratorTestUtils.createValue('123', 10, true);
+ value.valueDisplay = 'Red';
+ currentAttribute.label = 'Color';
+ expect(
+ classUnderTest['getAriaLabelGeneric'](currentAttribute, value, true)
+ ).toContain('configurator.a11y.selectedValueOfAttributeFull');
+ });
+ });
+
+ describe('extractValuePriceFormulaParameters', () => {
+ it('maps quantity, prices and selection state from value', () => {
+ const value = ConfiguratorTestUtils.createValue('123', 10, true);
+ value.quantity = 2;
+ expect(classUnderTest.extractValuePriceFormulaParameters(value)).toEqual({
+ quantity: 2,
+ price: value.valuePrice,
+ priceTotal: value.valuePriceTotal,
+ isLightedUp: true,
+ });
+ });
+
+ it('returns empty option fields when value is undefined', () => {
+ expect(
+ classUnderTest.extractValuePriceFormulaParameters(undefined)
+ ).toEqual({
+ quantity: undefined,
+ price: undefined,
+ priceTotal: undefined,
+ isLightedUp: undefined,
+ });
+ });
+ });
+
+ describe('isLastSelected', () => {
+ it('delegates to ConfiguratorStorefrontUtilsService', () => {
+ const utils = TestBed.inject(
+ ConfiguratorStorefrontUtilsService
+ ) as unknown as {
+ isLastSelected: jasmine.Spy;
+ };
+ utils.isLastSelected = jasmine
+ .createSpy('isLastSelected')
+ .and.returnValue(true);
+
+ expect(classUnderTest.isLastSelected('attributeName', 'valueCode')).toBe(
+ true
+ );
+ expect(utils.isLastSelected).toHaveBeenCalledWith(
+ 'attributeName',
+ 'valueCode'
+ );
+ });
});
describe('isRequiredErrorMsg', () => {
@@ -723,10 +869,105 @@ describe('ConfiguratorAttributeBaseComponent', () => {
});
});
+ describe('getContainerRowInfoKey', () => {
+ it('should return undefined if neither minRows nor maxRows is set', () => {
+ expect(classUnderTest.getContainerRowInfoKey()).toBeUndefined();
+ });
+
+ it('should return undefined if minRows is 0 and maxRows is not set', () => {
+ expect(classUnderTest.getContainerRowInfoKey(0)).toBeUndefined();
+ });
+
+ it('should return undefined if minRows and maxRows are 0', () => {
+ expect(classUnderTest.getContainerRowInfoKey(0, 0)).toBeUndefined();
+ });
+
+ it('should return min/max translatable if both minRows and maxRows are set and differ', () => {
+ expect(classUnderTest.getContainerRowInfoKey(1, 4)).toEqual({
+ key: 'configurator.attribute.containerMinMaxRows',
+ params: { minRows: 1, maxRows: 4 },
+ });
+ });
+
+ it('should return min translatable if only minRows is set', () => {
+ expect(classUnderTest.getContainerRowInfoKey(1)).toEqual({
+ key: 'configurator.attribute.containerMinRows',
+ params: { count: 1 },
+ });
+ });
+
+ it('should return max translatable if only maxRows is set', () => {
+ expect(classUnderTest.getContainerRowInfoKey(undefined, 4)).toEqual({
+ key: 'configurator.attribute.containerMaxRows',
+ params: { count: 4 },
+ });
+ });
+
+ it('should treat minRows of 0 as no minimum', () => {
+ expect(classUnderTest.getContainerRowInfoKey(0, 4)).toEqual({
+ key: 'configurator.attribute.containerMaxRows',
+ params: { count: 4 },
+ });
+ });
+
+ it('should treat maxRows of 0 as no maximum', () => {
+ expect(classUnderTest.getContainerRowInfoKey(2, 0)).toEqual({
+ key: 'configurator.attribute.containerMinRows',
+ params: { count: 2 },
+ });
+ });
+
+ it('should return exact-count translatable if minRows equals maxRows', () => {
+ expect(classUnderTest.getContainerRowInfoKey(3, 3)).toEqual({
+ key: 'configurator.attribute.containerExactRows',
+ params: { count: 3 },
+ });
+ });
+ });
+
+ describe('getContainerRemainingRequiredCount', () => {
+ it('should return minRows when no rows are selected', () => {
+ expect(classUnderTest.getContainerRemainingRequiredCount(3, [])).toBe(3);
+ });
+
+ it('should subtract selected rows from minRows', () => {
+ expect(
+ classUnderTest.getContainerRemainingRequiredCount(4, [
+ { id: '1', selected: true },
+ { id: '2', selected: true },
+ { id: '3', selected: false },
+ ])
+ ).toBe(2);
+ });
+
+ it('should return 0 if minRows is not set', () => {
+ expect(
+ classUnderTest.getContainerRemainingRequiredCount(undefined, [])
+ ).toBe(0);
+ });
+
+ it('should return 0 if minRows is 0', () => {
+ expect(classUnderTest.getContainerRemainingRequiredCount(0, [])).toBe(0);
+ });
+
+ it('should treat undefined rows as no selection', () => {
+ expect(classUnderTest.getContainerRemainingRequiredCount(3)).toBe(3);
+ });
+
+ it('should default to 0 if remaining products are zero', () => {
+ expect(
+ classUnderTest.getContainerRemainingRequiredCount(2, [
+ { id: '1', selected: true },
+ { id: '2', selected: true },
+ ])
+ ).toBe(0);
+ });
+ });
+
describe('enrichValueWithPrice', () => {
const value: Configurator.Value = { valueCode: 'val', selected: true };
it('should return original value if no price is known', () => {
- expect(classUnderTest.enrichValueWithPrice(value, undefined)).toBe(value);
+ expect(classUnderTest.enrichValueWithPrice(value, {})).toBe(value);
});
it('should return new value if price is known', () => {
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.ts
index 17f714119a6..d7ed6e8cf79 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.ts
+++ b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.ts
@@ -5,7 +5,7 @@
*/
import { inject } from '@angular/core';
-import { TranslationService } from '@spartacus/core';
+import { Translatable, TranslationService } from '@spartacus/core';
import { Observable, of, take } from 'rxjs';
import { Configurator } from '../../../../core/model/configurator.model';
import { ConfiguratorUISettingsConfig } from '../../../config/configurator-ui-settings.config';
@@ -80,6 +80,23 @@ export class ConfiguratorAttributeBaseComponent {
);
}
+ /**
+ * Creates unique key for config message on the UI
+ *
+ * @param prefix for key depending on usage (e.g. uiType, label)
+ * @param attributeId - attribute id
+ * @param valueId - optional value id
+ */
+ createContainerUiKey(
+ prefix: string,
+ attributeId: string,
+ valueId?: string
+ ): string {
+ return valueId
+ ? this.createValueUiKey(prefix, attributeId, valueId)
+ : this.createAttributeUiKey(prefix, attributeId);
+ }
+
/**
* Creates unique key for config value to be sent to configurator
*
@@ -88,13 +105,20 @@ export class ConfiguratorAttributeBaseComponent {
*/
createAttributeValueIdForConfigurator(
currentAttribute: Configurator.Attribute,
- value: string
+ value?: string
): string {
- return this.createValueUiKey(
- this.getUiType(currentAttribute),
- currentAttribute.name,
- value
- );
+ if (value) {
+ return this.createValueUiKey(
+ this.getUiType(currentAttribute),
+ currentAttribute.name,
+ value
+ );
+ } else {
+ return this.createAttributeUiKey(
+ this.getUiType(currentAttribute),
+ currentAttribute.name
+ );
+ }
}
protected getUiType(attribute: Configurator.Attribute): string {
@@ -314,7 +338,7 @@ export class ConfiguratorAttributeBaseComponent {
* makes sense when CPQ is active. In case the method is called in the wrong context, an exception will
* be thrown
*
- * @param {Configurator.Attribute} Attribute
+ * @param {Configurator.Attribute} attribute
* @returns {number} Attribute code
*/
protected getAttributeCode(attribute: Configurator.Attribute): number {
@@ -372,6 +396,89 @@ export class ConfiguratorAttributeBaseComponent {
return true;
}
+ /**
+ * Retrieves the translatable for container min/max row information.
+ * A bound of 0 is treated as unset so it does not appear in the text.
+ * When both bounds are set and equal, an exact-count message is used.
+ *
+ * @param minRows - optional minimum row count
+ * @param maxRows - optional maximum row count
+ * @returns the translatable, or `undefined` if there is no meaningful bound
+ */
+ getContainerRowInfoKey(
+ minRows?: number,
+ maxRows?: number
+ ): Translatable | undefined {
+ const hasMinRows = minRows != null && minRows > 0;
+ const hasMaxRows = maxRows != null && maxRows > 0;
+
+ if (hasMinRows && hasMaxRows) {
+ if (minRows === maxRows) {
+ return {
+ key: 'configurator.attribute.containerExactRows',
+ params: { count: minRows },
+ };
+ }
+ return {
+ key: 'configurator.attribute.containerMinMaxRows',
+ params: { minRows, maxRows },
+ };
+ }
+ if (hasMinRows) {
+ return {
+ key: 'configurator.attribute.containerMinRows',
+ params: { count: minRows },
+ };
+ }
+ if (hasMaxRows) {
+ return {
+ key: 'configurator.attribute.containerMaxRows',
+ params: { count: maxRows },
+ };
+ }
+ return undefined;
+ }
+
+ /**
+ * Remaining products needed to meet the container `minRows` requirement.
+ * A bound of 0 or an unset value is treated as no minimum.
+ *
+ * @param minRows - optional minimum row count
+ * @param rows - optional container rows used to count selected products
+ * @returns remaining product count
+ */
+ getContainerRemainingRequiredCount(
+ minRows?: number,
+ rows?: Configurator.ContainerRow[]
+ ): number {
+ const effectiveMinRows = minRows != null && minRows > 0 ? minRows : 0;
+ const selectedRows = rows?.filter((row) => row.selected).length ?? 0;
+ return Math.max(effectiveMinRows - selectedRows, 0);
+ }
+
+ /**
+ * Retrieves the translatable for the container required message.
+ *
+ * @param minRows - optional minimum row count
+ * @param rows - optional container rows used to count selected products
+ * @returns translatable for the required message
+ */
+ getContainerRequiredMessageKey(
+ minRows?: number,
+ rows?: Configurator.ContainerRow[]
+ ): Translatable | undefined {
+ const count = this.getContainerRemainingRequiredCount(minRows, rows);
+ if (count && count >= 1) {
+ return {
+ key: 'configurator.attribute.containerRequiredMessage',
+ params: {
+ count: this.getContainerRemainingRequiredCount(minRows, rows),
+ },
+ };
+ }
+ return undefined;
+ }
+
/**
* Retrieves the length of the value description.
*
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/container/configurator-attribute-container.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/container/configurator-attribute-container.component.html
index 4aed16573de..b42b0fd7501 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/types/container/configurator-attribute-container.component.html
+++ b/feature-libs/product-configurator/rulebased/components/attribute/types/container/configurator-attribute-container.component.html
@@ -31,7 +31,7 @@
{
- return of(ConfiguratorTestUtils.createConfiguration('config-id'));
+ return this.configuration$;
}
}
@@ -188,7 +192,9 @@ describe('ConfiguratorAttributeContainerComponent', () => {
ConfiguratorUtilsService,
{
provide: ConfiguratorStorefrontUtilsService,
- useValue: {},
+ useValue: {
+ isCartEntryOrGroupVisited: () => of(true),
+ },
},
],
})
@@ -605,21 +611,24 @@ describe('ConfiguratorAttributeContainerComponent', () => {
'cx-configurator-attribute-product-card'
);
expect(cards[0].id).toBe(
- component.createAttributeValueIdForConfigurator(
- component.attribute,
+ component.createValueUiKey(
+ 'selected-products',
+ component.attribute.name,
'row-1'
)
);
expect(cards[1].id).toBe(
- component.createAttributeValueIdForConfigurator(
- component.attribute,
- 'row-2'
+ component.createValueUiKey(
+ 'available-products',
+ component.attribute.name,
+ '0'
)
);
expect(cards[2].id).toBe(
- component.createAttributeValueIdForConfigurator(
- component.attribute,
- 'row-3'
+ component.createValueUiKey(
+ 'available-products',
+ component.attribute.name,
+ '1'
)
);
});
@@ -678,8 +687,8 @@ describe('ConfiguratorAttributeContainerComponent', () => {
);
expect(options.multiSelect).toBe(true);
- expect(options.attributeId).toBe(1111);
- expect(options.attributeName).toBe('attributeName');
+ expect(options.attribute.attrCode).toBe(1111);
+ expect(options.attribute.name).toBe('attributeName');
expect(options.itemCount).toBe(1);
expect(options.itemIndex).toBe(0);
expect(options.productBoundValue).toEqual({
@@ -715,6 +724,39 @@ describe('ConfiguratorAttributeContainerComponent', () => {
expect(options.loading$).toBe(component.loading$);
});
+ it('should reflect selection state via containerRow.selected on all cards', () => {
+ const selectedRow = component.selectedProducts[0];
+ const selectedCardOptions = component.extractProductCardParameters(
+ selectedRow,
+ 0,
+ component.selectedProducts.length
+ );
+ const availableRow = component.availableProducts[0];
+ const firstAvailableCardOptions = component.extractProductCardParameters(
+ availableRow,
+ 0,
+ component.availableProducts.length
+ );
+ const secondAvailableCardOptions = component.extractProductCardParameters(
+ component.availableProducts[1],
+ 1,
+ component.availableProducts.length
+ );
+
+ expect(selectedCardOptions.containerRow?.selected).toBe(true);
+ expect(firstAvailableCardOptions.containerRow?.selected).toBeFalsy();
+ expect(secondAvailableCardOptions.containerRow?.selected).toBeFalsy();
+ expect(firstAvailableCardOptions.attribute.container?.rows).toBe(
+ component.attribute.container?.rows
+ );
+ expect(firstAvailableCardOptions.attribute.required).toBe(
+ component.attribute.required
+ );
+ expect(firstAvailableCardOptions.attribute.incomplete).toBe(
+ component.attribute.incomplete
+ );
+ });
+
it('should pass the container row to the product card', () => {
const row = component.selectedProducts[0];
const options = component.extractProductCardParameters(
@@ -752,6 +794,746 @@ describe('ConfiguratorAttributeContainerComponent', () => {
});
});
+ describe('buildMessagesMap', () => {
+ const rowGroupId = 'CONTAINER_ROW@1111@row-1';
+
+ function configurationWithRowMessages(
+ groupId: string,
+ messages: Configurator.Message[]
+ ): Configurator.Configuration {
+ return {
+ ...ConfiguratorTestUtils.createConfiguration('config-id'),
+ groups: [
+ {
+ ...ConfiguratorTestUtils.createGroup(groupId),
+ messages,
+ },
+ ],
+ };
+ }
+
+ it('builds severity groups from row group messages on init', () => {
+ component.attribute = createAttribute([
+ {
+ id: 'row-1',
+ productName: 'Product A',
+ productSystemId: 'SYS_A',
+ selected: true,
+ groupId: rowGroupId,
+ },
+ ]);
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(
+ configurationWithRowMessages(rowGroupId, [
+ {
+ message: 'Too many units',
+ severity: Configurator.MessageSeverity.ERROR,
+ },
+ ])
+ )
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.selectedProducts[0],
+ 0,
+ 1
+ );
+ const errorGroup = options.messages?.find(
+ (group) => group.uiKeyPrefix === 'error-msg'
+ );
+ expect(errorGroup?.messages).toEqual(['Too many units']);
+ expect(errorGroup?.messageClass).toBe('cx-error-msg');
+ });
+
+ it('resolves messages from nested subgroups', () => {
+ const nestedGroup = {
+ ...ConfiguratorTestUtils.createGroup(rowGroupId),
+ messages: [
+ {
+ message: 'Nested error',
+ severity: Configurator.MessageSeverity.ERROR,
+ },
+ ],
+ };
+ component.attribute = createAttribute([
+ {
+ id: 'row-1',
+ productName: 'Product A',
+ productSystemId: 'SYS_A',
+ selected: true,
+ groupId: rowGroupId,
+ },
+ ]);
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of({
+ ...ConfiguratorTestUtils.createConfiguration('config-id'),
+ groups: [
+ {
+ ...ConfiguratorTestUtils.createGroup('parent-group'),
+ subGroups: [nestedGroup],
+ },
+ ],
+ })
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.selectedProducts[0],
+ 0,
+ 1
+ );
+ expect(
+ options.messages?.find((group) => group.uiKeyPrefix === 'error-msg')
+ ?.messages
+ ).toEqual(['Nested error']);
+ });
+
+ it('builds a separate map entry per container row', () => {
+ const selectedRowGroupId = 'CONTAINER_ROW@1111@row-1';
+ const availableRowGroupId = 'CONTAINER_ROW@1111@row-2';
+ component.attribute = createAttribute([
+ {
+ id: 'row-1',
+ productName: 'Product A',
+ productSystemId: 'SYS_A',
+ selected: true,
+ groupId: selectedRowGroupId,
+ },
+ {
+ id: 'row-2',
+ productName: 'Product B',
+ productSystemId: 'SYS_B',
+ selected: false,
+ groupId: availableRowGroupId,
+ },
+ ]);
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of({
+ ...ConfiguratorTestUtils.createConfiguration('config-id'),
+ groups: [
+ {
+ ...ConfiguratorTestUtils.createGroup(selectedRowGroupId),
+ messages: [
+ {
+ message: 'Selected row error',
+ severity: Configurator.MessageSeverity.ERROR,
+ },
+ ],
+ },
+ {
+ ...ConfiguratorTestUtils.createGroup(availableRowGroupId),
+ messages: [
+ {
+ message: 'Available row info',
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ ],
+ },
+ ],
+ })
+ );
+
+ component.ngOnInit();
+
+ const selectedOptions = component.extractProductCardParameters(
+ component.selectedProducts[0],
+ 0,
+ 1
+ );
+ const availableOptions = component.extractProductCardParameters(
+ component.availableProducts[0],
+ 0,
+ 1
+ );
+
+ expect(
+ selectedOptions.messages?.find(
+ (group) => group.uiKeyPrefix === 'error-msg'
+ )?.messages
+ ).toEqual(['Selected row error']);
+ expect(
+ availableOptions.messages?.find(
+ (group) => group.uiKeyPrefix === 'info-msg'
+ )?.messages
+ ).toEqual(['Available row info']);
+ });
+
+ it('updates row messages when configuration changes', () => {
+ const configuration$ = new BehaviorSubject(
+ configurationWithRowMessages(rowGroupId, [
+ {
+ message: 'Initial error',
+ severity: Configurator.MessageSeverity.ERROR,
+ },
+ ])
+ );
+ component.attribute = createAttribute([
+ {
+ id: 'row-1',
+ productName: 'Product A',
+ productSystemId: 'SYS_A',
+ selected: true,
+ groupId: rowGroupId,
+ },
+ ]);
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ configuration$
+ );
+ component.ngOnInit();
+
+ let options = component.extractProductCardParameters(
+ component.selectedProducts[0],
+ 0,
+ 1
+ );
+ expect(
+ options.messages?.find((group) => group.uiKeyPrefix === 'error-msg')
+ ?.messages
+ ).toEqual(['Initial error']);
+
+ configuration$.next(
+ configurationWithRowMessages(rowGroupId, [
+ {
+ message: 'Updated error',
+ severity: Configurator.MessageSeverity.ERROR,
+ },
+ ])
+ );
+
+ options = component.extractProductCardParameters(
+ component.selectedProducts[0],
+ 0,
+ 1
+ );
+ expect(
+ options.messages?.find((group) => group.uiKeyPrefix === 'error-msg')
+ ?.messages
+ ).toEqual(['Updated error']);
+ });
+ });
+
+ describe('getRowMessageGroups', () => {
+ const selectedRowGroupId = 'CONTAINER_ROW@1111@row-1';
+ const availableRowGroupId = 'CONTAINER_ROW@1111@row-2';
+
+ function configurationWithMessages(
+ groupId: string,
+ messages: Configurator.Message[]
+ ): Configurator.Configuration {
+ return {
+ ...ConfiguratorTestUtils.createConfiguration('config-id'),
+ groups: [
+ {
+ ...ConfiguratorTestUtils.createGroup(groupId),
+ messages,
+ },
+ ],
+ };
+ }
+
+ function mockVisited(visited: boolean): void {
+ const utils = TestBed.inject(ConfiguratorStorefrontUtilsService);
+ spyOn(utils, 'isCartEntryOrGroupVisited').and.returnValue(of(visited));
+ }
+
+ describe('selected rows', () => {
+ it('includes error severity groups from row group messages', () => {
+ component.attribute = createAttribute([
+ {
+ id: 'row-1',
+ productName: 'Product A',
+ productSystemId: 'SYS_A',
+ selected: true,
+ groupId: selectedRowGroupId,
+ },
+ ]);
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(
+ configurationWithMessages(selectedRowGroupId, [
+ {
+ message: 'Some info',
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ {
+ message: 'Too many units',
+ severity: Configurator.MessageSeverity.ERROR,
+ },
+ ])
+ )
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.selectedProducts[0],
+ 0,
+ 1
+ );
+ const prefixes = options.messages?.map((group) => group.uiKeyPrefix);
+ expect(prefixes).toContain('error-msg');
+ expect(prefixes).not.toContain('info-msg');
+ });
+
+ it('excludes container info messages', () => {
+ component.attribute = {
+ name: 'attributeName',
+ attrCode: 1111,
+ uiType: Configurator.UiType.CONTAINER,
+ required: true,
+ groupId: 'testGroup',
+ container: {
+ maxRows: 4,
+ rows: [
+ {
+ id: 'row-1',
+ productName: 'Product A',
+ productSystemId: 'SYS_A',
+ selected: true,
+ groupId: selectedRowGroupId,
+ },
+ ],
+ },
+ };
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(configurationWithMessages(selectedRowGroupId, []))
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.selectedProducts[0],
+ 0,
+ 1
+ );
+ expect(
+ options.messages?.some(
+ (group) => group.uiKeyPrefix === 'row-container-info-msg'
+ )
+ ).toBeFalsy();
+ });
+
+ it('excludes warning messages', () => {
+ component.attribute = createAttribute([
+ {
+ id: 'row-1',
+ productName: 'Product A',
+ productSystemId: 'SYS_A',
+ selected: true,
+ groupId: selectedRowGroupId,
+ },
+ ]);
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(
+ configurationWithMessages(selectedRowGroupId, [
+ {
+ message: 'Too many units',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ])
+ )
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.selectedProducts[0],
+ 0,
+ 1
+ );
+ expect(
+ options.messages?.map((group) => group.uiKeyPrefix)
+ ).not.toContain('warning-msg');
+ });
+ });
+
+ describe('unselected rows', () => {
+ it('prepends container info when row bounds are set', () => {
+ component.attribute = {
+ name: 'attributeName',
+ attrCode: 1111,
+ uiType: Configurator.UiType.CONTAINER,
+ required: true,
+ groupId: 'testGroup',
+ container: {
+ rows: [
+ {
+ id: 'row-2',
+ productName: 'Product B',
+ productSystemId: 'SYS_B',
+ selected: false,
+ groupId: availableRowGroupId,
+ maxRows: 4,
+ },
+ ],
+ },
+ };
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(configurationWithMessages(availableRowGroupId, []))
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.availableProducts[0],
+ 0,
+ 1
+ );
+ expect(options.messages?.map((group) => group.uiKeyPrefix)).toContain(
+ 'row-container-info-msg'
+ );
+ });
+
+ it('includes info and excludes engine errors', () => {
+ component.attribute = {
+ name: 'attributeName',
+ attrCode: 1111,
+ uiType: Configurator.UiType.CONTAINER,
+ groupId: 'testGroup',
+ container: {
+ rows: [
+ {
+ id: 'row-2',
+ productName: 'Product B',
+ productSystemId: 'SYS_B',
+ selected: false,
+ groupId: availableRowGroupId,
+ },
+ ],
+ },
+ };
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(
+ configurationWithMessages(availableRowGroupId, [
+ {
+ message: 'Some info',
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ {
+ message: 'Engine error',
+ severity: Configurator.MessageSeverity.ERROR,
+ },
+ ])
+ )
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.availableProducts[0],
+ 0,
+ 1
+ );
+ const prefixes = options.messages?.map((group) => group.uiKeyPrefix);
+ expect(prefixes).toContain('info-msg');
+ expect(prefixes).not.toContain('error-msg');
+ });
+
+ it('includes warning messages', () => {
+ component.attribute = {
+ name: 'attributeName',
+ attrCode: 1111,
+ uiType: Configurator.UiType.CONTAINER,
+ groupId: 'testGroup',
+ container: {
+ rows: [
+ {
+ id: 'row-2',
+ productName: 'Product B',
+ productSystemId: 'SYS_B',
+ selected: false,
+ groupId: availableRowGroupId,
+ },
+ ],
+ },
+ };
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(
+ configurationWithMessages(availableRowGroupId, [
+ {
+ message: 'Too many units',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ])
+ )
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.availableProducts[0],
+ 0,
+ 1
+ );
+ expect(options.messages?.map((group) => group.uiKeyPrefix)).toContain(
+ 'warning-msg'
+ );
+ });
+
+ it('places container info and required before engine messages', () => {
+ mockVisited(true);
+ component.attribute = {
+ name: 'attributeName',
+ attrCode: 1111,
+ uiType: Configurator.UiType.CONTAINER,
+ required: true,
+ incomplete: true,
+ groupId: 'testGroup',
+ container: {
+ minRows: 2,
+ rows: [
+ {
+ id: 'row-2',
+ productName: 'Product B',
+ productSystemId: 'SYS_B',
+ selected: false,
+ groupId: availableRowGroupId,
+ minRows: 2,
+ maxRows: 4,
+ },
+ ],
+ },
+ };
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(
+ configurationWithMessages(availableRowGroupId, [
+ {
+ message: 'Engine info',
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ ])
+ )
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.availableProducts[0],
+ 0,
+ 1
+ );
+ expect(options.messages?.map((group) => group.uiKeyPrefix)).toEqual([
+ 'row-container-info-msg',
+ 'row-required-msg',
+ 'info-msg',
+ ]);
+ expect(
+ options.messages?.find(
+ (group) => group.uiKeyPrefix === 'row-container-info-msg'
+ )?.messageClass
+ ).toBe('cx-container-info-msg');
+ expect(
+ options.messages?.find(
+ (group) => group.uiKeyPrefix === 'row-required-msg'
+ )?.messageClass
+ ).toBe('cx-container-error-msg');
+ });
+ });
+
+ describe('without row groupId', () => {
+ it('returns empty groups when row has no groupId', () => {
+ component.attribute = createAttribute([
+ {
+ id: 'row-1',
+ productName: 'Product A',
+ productSystemId: 'SYS_A',
+ selected: true,
+ },
+ ]);
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(ConfiguratorTestUtils.createConfiguration('config-id'))
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.selectedProducts[0],
+ 0,
+ 1
+ );
+ expect(options.messages).toEqual([]);
+ });
+
+ it('returns empty groups when row is not in the cache', () => {
+ const options = component.extractProductCardParameters(
+ {
+ id: 'unknown-row',
+ productName: 'Product Z',
+ productSystemId: 'SYS_Z',
+ selected: false,
+ },
+ 0,
+ 1
+ );
+
+ expect(options.messages).toEqual([]);
+ });
+ });
+
+ describe('required message gating', () => {
+ it('prepends required message when visited, required and incomplete', () => {
+ mockVisited(true);
+ component.attribute = {
+ name: 'attributeName',
+ attrCode: 1111,
+ uiType: Configurator.UiType.CONTAINER,
+ required: true,
+ incomplete: true,
+ groupId: 'testGroup',
+ container: {
+ minRows: 2,
+ rows: [
+ {
+ id: 'row-2',
+ productName: 'Product B',
+ productSystemId: 'SYS_B',
+ selected: false,
+ groupId: availableRowGroupId,
+ minRows: 2,
+ },
+ ],
+ },
+ };
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(configurationWithMessages(availableRowGroupId, []))
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.availableProducts[0],
+ 0,
+ 1
+ );
+ expect(options.messages?.map((group) => group.uiKeyPrefix)).toContain(
+ 'row-required-msg'
+ );
+ });
+
+ it('omits required message when parent group has no id', () => {
+ TestBed.inject(ConfiguratorAttributeCompositionContext).group = {
+ id: undefined as unknown as string,
+ subGroups: [],
+ };
+ component.attribute = {
+ name: 'attributeName',
+ attrCode: 1111,
+ uiType: Configurator.UiType.CONTAINER,
+ required: true,
+ incomplete: true,
+ groupId: 'testGroup',
+ container: {
+ minRows: 2,
+ rows: [
+ {
+ id: 'row-2',
+ productName: 'Product B',
+ productSystemId: 'SYS_B',
+ selected: false,
+ groupId: availableRowGroupId,
+ minRows: 2,
+ },
+ ],
+ },
+ };
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(configurationWithMessages(availableRowGroupId, []))
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.availableProducts[0],
+ 0,
+ 1
+ );
+ expect(
+ options.messages?.map((group) => group.uiKeyPrefix)
+ ).not.toContain('row-required-msg');
+ });
+
+ it('omits required message when group has not been visited', () => {
+ mockVisited(false);
+ component.attribute = {
+ name: 'attributeName',
+ attrCode: 1111,
+ uiType: Configurator.UiType.CONTAINER,
+ required: true,
+ incomplete: true,
+ groupId: 'testGroup',
+ container: {
+ minRows: 2,
+ rows: [
+ {
+ id: 'row-2',
+ productName: 'Product B',
+ productSystemId: 'SYS_B',
+ selected: false,
+ groupId: availableRowGroupId,
+ minRows: 2,
+ },
+ ],
+ },
+ };
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(configurationWithMessages(availableRowGroupId, []))
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.availableProducts[0],
+ 0,
+ 1
+ );
+ expect(
+ options.messages?.map((group) => group.uiKeyPrefix)
+ ).not.toContain('row-required-msg');
+ });
+
+ it('omits required message when attribute is complete', () => {
+ mockVisited(true);
+ component.attribute = {
+ name: 'attributeName',
+ attrCode: 1111,
+ uiType: Configurator.UiType.CONTAINER,
+ required: true,
+ incomplete: false,
+ groupId: 'testGroup',
+ container: {
+ minRows: 2,
+ rows: [
+ {
+ id: 'row-2',
+ productName: 'Product B',
+ productSystemId: 'SYS_B',
+ selected: false,
+ groupId: availableRowGroupId,
+ minRows: 2,
+ },
+ ],
+ },
+ };
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(configurationWithMessages(availableRowGroupId, []))
+ );
+
+ component.ngOnInit();
+
+ const options = component.extractProductCardParameters(
+ component.availableProducts[0],
+ 0,
+ 1
+ );
+ expect(
+ options.messages?.map((group) => group.uiKeyPrefix)
+ ).not.toContain('row-required-msg');
+ });
+ });
+ });
+
describe('onAdd', () => {
it('should call addContainerRow when the `ADD` button is clicked', () => {
spyOn(configuratorCommonsService, 'addContainerRow');
@@ -992,6 +1774,27 @@ describe('ConfiguratorAttributeContainerComponent', () => {
'row-1'
);
});
+
+ it('should ignore an unknown row action', () => {
+ spyOn(configuratorCommonsService, 'addContainerRow');
+ spyOn(configuratorCommonsService, 'removeContainerRow');
+ spyOn(configuratorCommonsService, 'copyContainerRow');
+ spyOn(component, 'onEdit');
+
+ component.onRowAction(
+ component.selectedProducts[0],
+ 'UNKNOWN' as Configurator.ContainerRowAction
+ );
+
+ expect(configuratorCommonsService.addContainerRow).not.toHaveBeenCalled();
+ expect(
+ configuratorCommonsService.removeContainerRow
+ ).not.toHaveBeenCalled();
+ expect(
+ configuratorCommonsService.copyContainerRow
+ ).not.toHaveBeenCalled();
+ expect(component.onEdit).not.toHaveBeenCalled();
+ });
});
describe('Accessibility', () => {
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/container/configurator-attribute-container.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/container/configurator-attribute-container.component.ts
index ff641ca4768..91607ee16aa 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/types/container/configurator-attribute-container.component.ts
+++ b/feature-libs/product-configurator/rulebased/components/attribute/types/container/configurator-attribute-container.component.ts
@@ -7,18 +7,27 @@
import { NgFor, NgIf, NgTemplateOutlet } from '@angular/common';
import {
ChangeDetectionStrategy,
+ ChangeDetectorRef,
Component,
ElementRef,
HostListener,
inject,
ViewChild,
+ OnInit,
} from '@angular/core';
import { TranslatePipe } from '@spartacus/core';
import { ICON_TYPE, IconComponent } from '@spartacus/storefront';
-import { take } from 'rxjs/operators';
+import { combineLatest, Observable, of } from 'rxjs';
+import { map, take } from 'rxjs/operators';
import { ConfiguratorGroupsService } from '../../../../core/facade/configurator-groups.service';
import { ConfiguratorUtilsService } from '../../../../core/facade/utils/configurator-utils.service';
+import {
+ ConfiguratorMessageService,
+ ConfiguratorMessageGroup,
+ ConfiguratorMessagesView,
+} from '../../../service/configurator-message.service';
import { Configurator } from '../../../../core/model/configurator.model';
+import { ConfiguratorStorefrontUtilsService } from '../../../service/configurator-storefront-utils.service';
import {
ConfiguratorAttributeProductCardComponent,
ConfiguratorAttributeProductCardComponentOptions,
@@ -43,9 +52,20 @@ import { ConfiguratorAttributeSelectionBaseComponent } from '../base/configurato
ConfiguratorAttributeProductCardComponent,
],
})
-export class ConfiguratorAttributeContainerComponent extends ConfiguratorAttributeSelectionBaseComponent {
+export class ConfiguratorAttributeContainerComponent
+ extends ConfiguratorAttributeSelectionBaseComponent
+ implements OnInit
+{
protected configuratorGroupsService = inject(ConfiguratorGroupsService);
protected configuratorUtilsService = inject(ConfiguratorUtilsService);
+ protected configuratorMessageService = inject(ConfiguratorMessageService);
+ protected configuratorStorefrontUtilsService = inject(
+ ConfiguratorStorefrontUtilsService
+ );
+ protected changeDetectorRef = inject(ChangeDetectorRef);
+
+ /** Cached mapping from container row id to pre-built message groups. */
+ protected messagesMap: Record = {};
attribute: Configurator.Attribute;
ownerKey: string;
@@ -81,6 +101,157 @@ export class ConfiguratorAttributeContainerComponent extends ConfiguratorAttribu
this.ownerKey = this.attributeComponentContext.owner.key;
}
+ ngOnInit(): void {
+ const owner = this.attributeComponentContext.owner;
+ const groupId = this.attributeComponentContext.group.id;
+ this.subscription.add(
+ combineLatest([
+ this.configuratorCommonsService.getConfiguration(owner),
+ this.getShowRequiredMessage$(groupId),
+ ]).subscribe(([configuration, showRequiredMessage]) => {
+ this.messagesMap = this.buildMessagesMap(
+ configuration,
+ showRequiredMessage
+ );
+ this.changeDetectorRef.markForCheck();
+ })
+ );
+ }
+
+ /**
+ * Builds the mapping from container row id to pre-built message groups for
+ * the given configuration. The expensive nested-group lookup and message
+ * enrichment is performed once per configuration update instead of once per
+ * rendered product card.
+ *
+ * @param configuration - Current configuration
+ * @param showRequiredMessage - Whether the container required message should
+ * be shown (parent group visited and attribute required and incomplete)
+ * @returns Mapping from row id to message groups
+ */
+ protected buildMessagesMap(
+ configuration: Configurator.Configuration,
+ showRequiredMessage: boolean
+ ): Record {
+ const messagesMap: Record = {};
+ const rows = this.getContainerRows();
+ rows.forEach((row) => {
+ const view = this.getRowMessages(
+ configuration,
+ row,
+ rows,
+ showRequiredMessage
+ );
+ messagesMap[row.id] = this.getRowMessageGroups(view, !!row.selected);
+ });
+ return messagesMap;
+ }
+
+ /**
+ * Determines the messages to display for the given container row. When the
+ * row is not selected, the row min/max info and (when applicable) the
+ * required error are included before the row-level engine messages.
+ *
+ * @param configuration - Current configuration
+ * @param row - Container row the messages belong to
+ * @param rows - All container rows, used to compute the required count
+ * @param showRequiredMessage - Whether the required message should be shown
+ * @returns Messages of the nested configuration of the given container row
+ */
+ protected getRowMessages(
+ configuration: Configurator.Configuration,
+ row: Configurator.ContainerRow,
+ rows: Configurator.ContainerRow[],
+ showRequiredMessage: boolean
+ ): ConfiguratorMessagesView {
+ const group = row.groupId
+ ? this.configuratorUtilsService.getOptionalGroupById(
+ configuration.groups,
+ row.groupId
+ )
+ : undefined;
+ const engineMessages =
+ this.configuratorMessageService.splitMessagesBySeverity(group?.messages);
+
+ if (row.selected) {
+ return engineMessages;
+ }
+
+ return this.configuratorMessageService.enrichMessagesWithContainerContext(
+ engineMessages,
+ {
+ minRows: row.minRows,
+ maxRows: row.maxRows,
+ rows: rows,
+ includeContainerInfo: true,
+ includeRequiredError: showRequiredMessage,
+ getContainerRowInfoKey: (minRows, maxRows) =>
+ this.getContainerRowInfoKey(minRows, maxRows),
+ getContainerRequiredMessageKey: (minRows, containerRows) =>
+ this.getContainerRequiredMessageKey(minRows, containerRows),
+ }
+ );
+ }
+
+ /**
+ * Filters the given messages by the product selection state and builds the
+ * info, warning, error, container info and required message groups of the
+ * bound container row.
+ *
+ * @param view - Messages of the bound container row
+ * @param selected - Whether the row (product) is selected
+ * @returns Message groups
+ */
+ protected getRowMessageGroups(
+ view: ConfiguratorMessagesView,
+ selected: boolean
+ ): ConfiguratorMessageGroup[] {
+ const messagesView =
+ this.configuratorMessageService.filterMessagesByProductSelection(
+ view,
+ selected
+ );
+
+ return this.configuratorMessageService.prependContainerContextMessageGroups(
+ messagesView,
+ {
+ containerInfoMessageClass: 'cx-container-info-msg',
+ requiredErrorMessageClass: 'cx-container-error-msg',
+ iconTypeError: ICON_TYPE.ERROR,
+ containerInfoUiKeyPrefix: 'row-container-info-msg',
+ requiredErrorUiKeyPrefix: 'row-required-msg',
+ }
+ );
+ }
+
+ /**
+ * Whether the container required message should be considered.
+ *
+ * @returns `true` when the parent attribute is required and incomplete
+ */
+ protected shouldShowContainerRequiredMessage(): boolean {
+ return !!this.attribute.required && !!this.attribute.incomplete;
+ }
+
+ /**
+ * Resolves whether the required message can be shown for the parent group.
+ * The message is only shown once the group (or cart entry) has been visited
+ * and the attribute is required and incomplete.
+ *
+ * @param groupId - Parent group id
+ * @returns Observable that emits whether the required message is shown
+ */
+ protected getShowRequiredMessage$(groupId?: string): Observable {
+ if (!groupId) {
+ return of(false);
+ }
+ return this.configuratorStorefrontUtilsService
+ .isCartEntryOrGroupVisited(this.attributeComponentContext.owner, groupId)
+ .pipe(
+ map((visited) => visited && this.shouldShowContainerRequiredMessage())
+ );
+ }
+
/**
* Selected container rows shown in the "Selected Products" section.
*/
@@ -335,6 +506,7 @@ export class ConfiguratorAttributeContainerComponent extends ConfiguratorAttribu
return {
multiSelect: true,
productBoundValue: this.mapRowToValue(row),
+ attribute: this.attribute,
attributeId: this.getAttributeCode(this.attribute),
attributeLabel: this.attribute.label,
attributeName: this.attribute.name,
@@ -342,6 +514,7 @@ export class ConfiguratorAttributeContainerComponent extends ConfiguratorAttribu
itemIndex: index,
loading$: this.loading$,
containerRow: row,
+ messages: this.messagesMap[row.id] ?? [],
};
}
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-bundle/configurator-attribute-multi-selection-bundle.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-bundle/configurator-attribute-multi-selection-bundle.component.ts
index a57d5ae2285..09155bca586 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-bundle/configurator-attribute-multi-selection-bundle.component.ts
+++ b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-bundle/configurator-attribute-multi-selection-bundle.component.ts
@@ -248,6 +248,7 @@ export class ConfiguratorAttributeMultiSelectionBundleComponent
disableAllButtons: disableAllButtons ?? false,
hideRemoveButton: hideRemoveButton ?? false,
productBoundValue: value,
+ attribute: this.attribute,
multiSelect: true,
withQuantity: this.withQuantity,
loading$: this.loading$,
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-bundle-dropdown/configurator-attribute-single-selection-bundle-dropdown.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-bundle-dropdown/configurator-attribute-single-selection-bundle-dropdown.component.ts
index 2a5035cae8c..dd8d29c1f3c 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-bundle-dropdown/configurator-attribute-single-selection-bundle-dropdown.component.ts
+++ b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-bundle-dropdown/configurator-attribute-single-selection-bundle-dropdown.component.ts
@@ -108,6 +108,7 @@ export class ConfiguratorAttributeSingleSelectionBundleDropdownComponent
return {
hideRemoveButton: true,
productBoundValue: this.selectedValue,
+ attribute: this.attribute,
singleDropdown: true,
withQuantity: false,
loading$: this.loading$,
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-bundle/configurator-attribute-single-selection-bundle.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-bundle/configurator-attribute-single-selection-bundle.component.ts
index 50645eea31b..9aa635cda5a 100644
--- a/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-bundle/configurator-attribute-single-selection-bundle.component.ts
+++ b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-bundle/configurator-attribute-single-selection-bundle.component.ts
@@ -44,6 +44,7 @@ export class ConfiguratorAttributeSingleSelectionBundleComponent extends Configu
hideRemoveButton: this.attribute.required,
fallbackFocusId: this.getFocusIdOfNearestValue(value),
productBoundValue: value,
+ attribute: this.attribute,
loading$: this.loading$,
attributeId: this.getAttributeCode(this.attribute),
attributeLabel: this.attribute.label,
diff --git a/feature-libs/product-configurator/rulebased/components/configurator-conflict-and-error-messages/configurator-conflict-and-error-messages.component.html b/feature-libs/product-configurator/rulebased/components/configurator-conflict-and-error-messages/configurator-conflict-and-error-messages.component.html
index 0c70484e746..900f717262d 100644
--- a/feature-libs/product-configurator/rulebased/components/configurator-conflict-and-error-messages/configurator-conflict-and-error-messages.component.html
+++ b/feature-libs/product-configurator/rulebased/components/configurator-conflict-and-error-messages/configurator-conflict-and-error-messages.component.html
@@ -1,5 +1,7 @@
-
- 0">
+
+ 0"
+ >
1"
+ *ngIf="
+ messages.infoMessages.length + messages.warningMessages.length > 1
+ "
>
{{ 'configurator.header.multipleWarnings' | cxTranslate }}
+ {{ warningMessage }}
+
+
{{ warningMessage }}
- 0">
+ 0">
-
1"
- >
+ 1">
{{ 'configurator.header.multipleErrors' | cxTranslate }}
{{ errorMessage }}
diff --git a/feature-libs/product-configurator/rulebased/components/configurator-conflict-and-error-messages/configurator-conflict-and-error-messages.component.spec.ts b/feature-libs/product-configurator/rulebased/components/configurator-conflict-and-error-messages/configurator-conflict-and-error-messages.component.spec.ts
index 6d36c21c26d..530d9aca062 100644
--- a/feature-libs/product-configurator/rulebased/components/configurator-conflict-and-error-messages/configurator-conflict-and-error-messages.component.spec.ts
+++ b/feature-libs/product-configurator/rulebased/components/configurator-conflict-and-error-messages/configurator-conflict-and-error-messages.component.spec.ts
@@ -3,6 +3,10 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { NgSelectModule } from '@ng-select/ng-select';
import { I18nTestingModule } from '@spartacus/core';
+import {
+ MockFeatureTogglesController,
+ provideMockFeatureToggles,
+} from 'core-libs/core/src/features-config/feature-toggles/testing';
import {
CommonConfigurator,
CommonConfiguratorUtilsService,
@@ -78,6 +82,75 @@ const configWithOnlyOneMessage: Configurator.Configuration = {
warningMessages: ['test warning message 1'],
};
+const ROOT_TAB_ID = '1';
+const ROW_GROUP_ID = 'CONTAINER_ROW@1067@row-1';
+const NESTED_TAB_ID = 'CONTAINER_ROW@1067@row-1@1';
+const INNER_ROW_GROUP_ID = 'CONTAINER_ROW@2000@row-2';
+const INNER_NESTED_TAB_ID = 'CONTAINER_ROW@2000@row-2@1';
+
+const nestedInfoMessage1 = 'test nested info message 1';
+const nestedInfoMessage2 = 'test nested info message 2';
+const nestedWarningMessage1 = 'test nested warning message 1';
+const nestedWarningMessage2 = 'test nested warning message 2';
+const innerNestedInfoMessage = 'test inner nested info message';
+const typedInfoMessage = 'typed info message';
+const typedWarningMessage = 'typed warning message';
+const typedUnspecifiedMessage = 'typed unspecified message';
+
+function createContainerRowGroup(
+ groupId: string,
+ subGroups: Configurator.Group[],
+ messages?: Configurator.Message[]
+): Configurator.Group {
+ return {
+ ...ConfiguratorTestUtils.createGroup(groupId),
+ groupType: Configurator.GroupType.CONTAINER_ROW_GROUP,
+ subGroups: subGroups,
+ messages: messages,
+ };
+}
+
+/**
+ * Creates a configuration with root level messages and a container row within a
+ * container row, so that the messages of the viewed nested configuration can be
+ * distinguished from the root and the enclosing ones.
+ */
+function createConfigWithContainerRows(
+ currentGroup: string,
+ rowGroupMessages?: Configurator.Message[]
+): Configurator.Configuration {
+ const innerRowGroup = createContainerRowGroup(
+ INNER_ROW_GROUP_ID,
+ [ConfiguratorTestUtils.createGroup(INNER_NESTED_TAB_ID)],
+ [
+ {
+ message: innerNestedInfoMessage,
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ ]
+ );
+ const rowGroup = createContainerRowGroup(
+ ROW_GROUP_ID,
+ [
+ {
+ ...ConfiguratorTestUtils.createGroup(NESTED_TAB_ID),
+ subGroups: [innerRowGroup],
+ },
+ ],
+ rowGroupMessages
+ );
+ return {
+ ...configWithMessages,
+ groups: [
+ {
+ ...ConfiguratorTestUtils.createGroup(ROOT_TAB_ID),
+ subGroups: [rowGroup],
+ },
+ ],
+ interactionState: { currentGroup: currentGroup },
+ };
+}
+
let configuration: Configurator.Configuration;
class MockConfiguratorRouterExtractorService {
@@ -110,6 +183,7 @@ describe('ConfiguratorConflictAndErrorMessagesComponent', () => {
let fixture: ComponentFixture
;
let configuratorUtils: CommonConfiguratorUtilsService;
let htmlElem: HTMLElement;
+ let featureToggles: MockFeatureTogglesController;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
@@ -128,6 +202,9 @@ describe('ConfiguratorConflictAndErrorMessagesComponent', () => {
useClass: MockConfiguratorCommonsService,
},
{ provide: IconLoaderService, useClass: MockIconFontLoaderService },
+ provideMockFeatureToggles({
+ productConfiguratorCPQContainer: false,
+ }),
],
}).overrideComponent(ConfiguratorConflictAndErrorMessagesComponent, {
remove: {
@@ -137,6 +214,9 @@ describe('ConfiguratorConflictAndErrorMessagesComponent', () => {
});
}));
beforeEach(() => {
+ featureToggles = TestBed.inject(MockFeatureTogglesController);
+ featureToggles.set('productConfiguratorCPQContainer', false);
+
fixture = TestBed.createComponent(
ConfiguratorConflictAndErrorMessagesComponent
);
@@ -278,6 +358,339 @@ describe('ConfiguratorConflictAndErrorMessagesComponent', () => {
);
});
+ describe('nested configuration', () => {
+ it('should render info and warning messages in the warning section', () => {
+ configuration = createConfigWithContainerRows(NESTED_TAB_ID, [
+ {
+ message: nestedInfoMessage1,
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ {
+ message: nestedWarningMessage1,
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ {
+ message: nestedInfoMessage2,
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ {
+ message: nestedWarningMessage2,
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ]);
+ component.toggleWarnings();
+ component.toggleErrors();
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message:nth-child(1)',
+ nestedInfoMessage1
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message:nth-child(2)',
+ nestedInfoMessage2
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message:nth-child(3)',
+ nestedWarningMessage1
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message:nth-child(4)',
+ nestedWarningMessage2
+ );
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.cx-error-message'
+ );
+ });
+
+ it('should not render the messages of the root configuration', () => {
+ configuration = createConfigWithContainerRows(NESTED_TAB_ID, [
+ {
+ message: nestedInfoMessage1,
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ ]);
+ fixture.detectChanges();
+
+ expect(htmlElem.textContent).not.toContain(errorMessage1);
+ expect(htmlElem.textContent).not.toContain(warningMessage1);
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.cx-error-message'
+ );
+ });
+
+ it('should render a message without severity as info in the warning section', () => {
+ configuration = createConfigWithContainerRows(NESTED_TAB_ID, [
+ { message: nestedInfoMessage1 },
+ ]);
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message',
+ nestedInfoMessage1
+ );
+ });
+
+ it('should not render any message if the nested configuration has none', () => {
+ configuration = createConfigWithContainerRows(NESTED_TAB_ID);
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ '.alert-message'
+ );
+ });
+
+ it('should only render the messages of the innermost nested configuration', () => {
+ configuration = createConfigWithContainerRows(INNER_NESTED_TAB_ID, [
+ {
+ message: nestedInfoMessage1,
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ ]);
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message',
+ innerNestedInfoMessage
+ );
+ expect(htmlElem.textContent).not.toContain(nestedInfoMessage1);
+ });
+
+ it('should render the messages of the root configuration if a root group is viewed', () => {
+ configuration = createConfigWithContainerRows(ROOT_TAB_ID, [
+ {
+ message: nestedInfoMessage1,
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ ]);
+ component.toggleWarnings();
+ component.toggleErrors();
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message:nth-child(1)',
+ warningMessage1
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-error-message:nth-child(1)',
+ errorMessage1
+ );
+ expect(htmlElem.textContent).not.toContain(nestedInfoMessage1);
+ });
+ });
+
+ describe('typed root messages with productConfiguratorCPQContainer', () => {
+ const typedRootMessages: Configurator.Message[] = [
+ {
+ message: typedInfoMessage,
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ {
+ message: typedWarningMessage,
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ { message: typedUnspecifiedMessage },
+ ];
+
+ function createConfigWithTypedRootMessages(
+ hasFullConfigurationState?: boolean,
+ messages?: Configurator.Message[]
+ ): Configurator.Configuration {
+ return {
+ ...configWithMessages,
+ hasFullConfigurationState,
+ messages,
+ };
+ }
+
+ it('should render legacy messages when the feature toggle is disabled even if hasFullConfigurationState is true', () => {
+ configuration = createConfigWithTypedRootMessages(
+ true,
+ typedRootMessages
+ );
+ component.toggleWarnings();
+ component.toggleErrors();
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message:nth-child(1)',
+ warningMessage1
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-error-message:nth-child(1)',
+ errorMessage1
+ );
+ expect(htmlElem.textContent).not.toContain(typedInfoMessage);
+ expect(htmlElem.textContent).not.toContain(typedWarningMessage);
+ });
+
+ it('should render legacy messages when hasFullConfigurationState is false', () => {
+ featureToggles.set('productConfiguratorCPQContainer', true);
+ configuration = createConfigWithTypedRootMessages(
+ false,
+ typedRootMessages
+ );
+ component.toggleWarnings();
+ component.toggleErrors();
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message:nth-child(1)',
+ warningMessage1
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-error-message:nth-child(1)',
+ errorMessage1
+ );
+ expect(htmlElem.textContent).not.toContain(typedInfoMessage);
+ });
+
+ it('should render legacy messages when hasFullConfigurationState is absent', () => {
+ featureToggles.set('productConfiguratorCPQContainer', true);
+ configuration = createConfigWithTypedRootMessages(
+ undefined,
+ typedRootMessages
+ );
+ component.toggleWarnings();
+ component.toggleErrors();
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message:nth-child(1)',
+ warningMessage1
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-error-message:nth-child(1)',
+ errorMessage1
+ );
+ });
+
+ it('should render only typed messages when the feature is enabled', () => {
+ featureToggles.set('productConfiguratorCPQContainer', true);
+ configuration = createConfigWithTypedRootMessages(
+ true,
+ typedRootMessages
+ );
+ component.toggleWarnings();
+ component.toggleErrors();
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message:nth-child(1)',
+ typedInfoMessage
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message:nth-child(2)',
+ typedUnspecifiedMessage
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message:nth-child(3)',
+ typedWarningMessage
+ );
+ expect(htmlElem.textContent).not.toContain(warningMessage1);
+ expect(htmlElem.textContent).not.toContain(errorMessage1);
+ expect(htmlElem.textContent).not.toContain(errorMessage2);
+ });
+
+ it('should not render legacy messages when typed messages are empty', () => {
+ featureToggles.set('productConfiguratorCPQContainer', true);
+ configuration = createConfigWithTypedRootMessages(true, []);
+ fixture.detectChanges();
+
+ expect(htmlElem.querySelector('.cx-warning-message')).toBeNull();
+ expect(htmlElem.querySelector('.cx-error-message')).toBeNull();
+ });
+
+ it('should render a message only once when it is present in both the typed and the legacy list', () => {
+ const duplicateMessage = 'Clean-Up services are needed in addition';
+ featureToggles.set('productConfiguratorCPQContainer', true);
+ configuration = {
+ ...configWOMessages,
+ hasFullConfigurationState: true,
+ warningMessages: [duplicateMessage],
+ messages: [
+ {
+ message: duplicateMessage,
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ };
+ fixture.detectChanges();
+
+ expect(htmlElem.querySelectorAll('.cx-warning-message').length).toBe(1);
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message',
+ duplicateMessage
+ );
+ });
+
+ it('should still render nested configuration messages rather than typed root messages', () => {
+ featureToggles.set('productConfiguratorCPQContainer', true);
+ configuration = {
+ ...createConfigWithContainerRows(NESTED_TAB_ID, [
+ {
+ message: nestedInfoMessage1,
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ ]),
+ hasFullConfigurationState: true,
+ messages: typedRootMessages,
+ };
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-warning-message',
+ nestedInfoMessage1
+ );
+ expect(htmlElem.textContent).not.toContain(typedInfoMessage);
+ expect(htmlElem.textContent).not.toContain(warningMessage1);
+ });
+ });
+
describe('Accessibility', () => {
beforeEach(() => {
configuration = configWithMessages;
diff --git a/feature-libs/product-configurator/rulebased/components/configurator-conflict-and-error-messages/configurator-conflict-and-error-messages.component.ts b/feature-libs/product-configurator/rulebased/components/configurator-conflict-and-error-messages/configurator-conflict-and-error-messages.component.ts
index 7a6bd688b7e..1e2207c266d 100644
--- a/feature-libs/product-configurator/rulebased/components/configurator-conflict-and-error-messages/configurator-conflict-and-error-messages.component.ts
+++ b/feature-libs/product-configurator/rulebased/components/configurator-conflict-and-error-messages/configurator-conflict-and-error-messages.component.ts
@@ -5,13 +5,18 @@
*/
import { AsyncPipe, NgFor, NgIf } from '@angular/common';
-import { ChangeDetectionStrategy, Component } from '@angular/core';
-import { TranslatePipe } from '@spartacus/core';
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { FeatureToggles, TranslatePipe } from '@spartacus/core';
import { ConfiguratorRouterExtractorService } from '@spartacus/product-configurator/common';
import { ICON_TYPE, IconComponent } from '@spartacus/storefront';
import { Observable } from 'rxjs';
-import { switchMap } from 'rxjs/operators';
+import { map, switchMap } from 'rxjs/operators';
import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service';
+import {
+ ConfiguratorMessageService,
+ ConfiguratorMessagesView,
+} from '../service/configurator-message.service';
+import { ConfiguratorUtilsService } from '../../core/facade/utils/configurator-utils.service';
import { Configurator } from '../../core/model/configurator.model';
@Component({
@@ -21,7 +26,18 @@ import { Configurator } from '../../core/model/configurator.model';
imports: [NgIf, IconComponent, NgFor, AsyncPipe, TranslatePipe],
})
export class ConfiguratorConflictAndErrorMessagesComponent {
+ protected configuratorUtilsService = inject(ConfiguratorUtilsService);
+ protected configuratorMessageService = inject(ConfiguratorMessageService);
+ private featureToggles = inject(FeatureToggles);
+
iconTypes = ICON_TYPE;
+
+ /**
+ * @deprecated since 221121.17 - Use `messages$` instead, which only exposes
+ * the messages of the configuration that is currently viewed. This
+ * observable remains for backward compatibility and will be removed in a
+ * future major version.
+ */
configuration$: Observable =
this.configRouterExtractorService
.extractRouterData()
@@ -31,6 +47,20 @@ export class ConfiguratorConflictAndErrorMessagesComponent {
)
);
+ /**
+ * Messages of the configuration the user currently views. While a nested
+ * (container row) configuration is viewed, only its messages are exposed,
+ * so that messages of the root configuration and of enclosing nested
+ * configurations do not appear.
+ */
+ messages$: Observable =
+ this.configRouterExtractorService.extractRouterData().pipe(
+ switchMap((routerData) =>
+ this.configuratorCommonsService.getConfiguration(routerData.owner)
+ ),
+ map((configuration) => this.getMessages(configuration))
+ );
+
showWarnings = false;
toggleWarnings(): void {
@@ -47,4 +77,80 @@ export class ConfiguratorConflictAndErrorMessagesComponent {
protected configuratorCommonsService: ConfiguratorCommonsService,
protected configRouterExtractorService: ConfiguratorRouterExtractorService
) {}
+
+ /**
+ * Determines the messages to display for the given configuration, taking the
+ * nested configuration that is currently viewed into account.
+ *
+ * When `productConfiguratorCPQContainer` is enabled and the configuration
+ * has the full CPQ state, root messages are taken from the typed
+ * `messages` list rather than from `warningMessages`/`errorMessages`.
+ *
+ * @param configuration - Current configuration
+ * @returns Messages of the currently viewed configuration
+ */
+ protected getMessages(
+ configuration: Configurator.Configuration
+ ): ConfiguratorMessagesView {
+ const containerRowGroup = this.getCurrentContainerRowGroup(configuration);
+ if (containerRowGroup) {
+ return this.configuratorMessageService.splitMessagesBySeverity(
+ containerRowGroup.messages
+ );
+ }
+ if (this.shouldUseTypedRootMessages(configuration)) {
+ return this.configuratorMessageService.splitMessagesBySeverity(
+ configuration.messages
+ );
+ }
+ return {
+ infoMessages: [],
+ warningMessages: configuration.warningMessages ?? [],
+ errorMessages: configuration.errorMessages ?? [],
+ };
+ }
+
+ /**
+ * Whether root messages should be read from the typed `messages` list.
+ *
+ * @param configuration - Current configuration
+ * @returns `true` when the CPQ container feature is enabled and the
+ * configuration has the full CPQ state
+ */
+ protected shouldUseTypedRootMessages(
+ configuration: Configurator.Configuration
+ ): boolean {
+ return (
+ !!this.featureToggles.productConfiguratorCPQContainer &&
+ !!configuration.hasFullConfigurationState
+ );
+ }
+
+ /**
+ * Retrieves the group that carries the nested configuration the user
+ * currently views. As the group path is collected innermost first, the first
+ * container row group on it is the one being viewed. For a container within a
+ * container this is the innermost one.
+ *
+ * @param configuration - Current configuration
+ * @returns Container row group on the path to the current group, or
+ * `undefined` if the root configuration is viewed
+ */
+ protected getCurrentContainerRowGroup(
+ configuration: Configurator.Configuration
+ ): Configurator.Group | undefined {
+ const currentGroupId = configuration.interactionState?.currentGroup;
+ if (!currentGroupId || !configuration.groups?.length) {
+ return undefined;
+ }
+ const groupPath: Configurator.Group[] = [];
+ this.configuratorUtilsService.buildGroupPath(
+ currentGroupId,
+ configuration.groups,
+ groupPath
+ );
+ return groupPath.find(
+ (group) => group.groupType === Configurator.GroupType.CONTAINER_ROW_GROUP
+ );
+ }
}
diff --git a/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.service.spec.ts b/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.service.spec.ts
index 44eb9c6be14..8e37aff7d76 100644
--- a/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.service.spec.ts
+++ b/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.service.spec.ts
@@ -59,11 +59,16 @@ describe('ConfiguratorGroupMenuService', () => {
expect(classUnderTest).toBeTruthy();
});
- describe('getFocusedElementTabIndex', () => {
- it('should return index of focused element', () => {
+ describe('getFocusedGroupIndex', () => {
+ it('returns index of focused element', () => {
groups.toArray()[2].nativeElement?.focus();
expect(classUnderTest['getFocusedGroupIndex'](groups)).toBe(2);
});
+
+ it('returns undefined when no group element has focus', () => {
+ (document.activeElement as HTMLElement)?.blur?.();
+ expect(classUnderTest['getFocusedGroupIndex'](groups)).toBeUndefined();
+ });
});
describe('updateCurrentGroupIndex', () => {
@@ -127,6 +132,14 @@ describe('ConfiguratorGroupMenuService', () => {
fail('Group menu not available');
}
});
+
+ it('syncs from focused element when current index differs', () => {
+ groups.toArray()[2].nativeElement?.focus();
+
+ classUnderTest['focusNextGroup'](0, groups);
+
+ expect(document.activeElement?.id).toBe('groupId-0');
+ });
});
describe('focusPreviousGroup', () => {
@@ -205,9 +218,35 @@ describe('ConfiguratorGroupMenuService', () => {
focusedElement = document.activeElement;
expect(focusedElement?.id).toBe('groupId-1');
});
+
+ it('calls preventDefault on every key press', () => {
+ const event = new KeyboardEvent('keydown', { code: 'ArrowDown' });
+ spyOn(event, 'preventDefault');
+
+ classUnderTest.switchGroupOnArrowPress(event, 0, groups);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ });
+
+ it('does not move focus for non-arrow keys', () => {
+ groups.toArray()[0].nativeElement?.focus();
+ const event = new KeyboardEvent('keydown', { code: 'Enter' });
+
+ classUnderTest.switchGroupOnArrowPress(event, 0, groups);
+
+ expect(document.activeElement?.id).toBe('groupId-0');
+ });
});
describe('isBackBtnFocused', () => {
+ it('should return `undefined` because no group list is provided', () => {
+ expect(
+ classUnderTest['isBackBtnFocused'](
+ undefined as unknown as QueryList>
+ )
+ ).toBeUndefined();
+ });
+
it('should return `false` because there is no `cx-menu-back` in the group menu', () => {
groups.toArray()[2].nativeElement?.focus();
expect(classUnderTest['isBackBtnFocused'](groups)).toBe(false);
@@ -228,6 +267,24 @@ describe('ConfiguratorGroupMenuService', () => {
fail('Group menu not available');
}
});
+
+ it('returns false when back button exists but is not focused', () => {
+ const backButton = createBackButton();
+ const groupMenu = document.querySelector(
+ 'main cx-configurator-group-menu'
+ );
+ if (!groupMenu) {
+ fail('Group menu not available');
+ return;
+ }
+ groupMenu.prepend(backButton);
+ const array = groups.toArray();
+ array.unshift(new ElementRef(backButton));
+ groups.reset(array);
+ groups.toArray()[1].nativeElement?.focus();
+
+ expect(classUnderTest.isBackBtnFocused(groups)).toBe(false);
+ });
});
describe('isActiveGroupInGroupList', () => {
@@ -241,5 +298,11 @@ describe('ConfiguratorGroupMenuService', () => {
.nativeElement.setAttribute('class', 'active cx-menu-item');
expect(classUnderTest['isActiveGroupInGroupList'](groups)).toBe(true);
});
+
+ it('returns false when groups query list is empty', () => {
+ const emptyGroups = new QueryList>();
+ emptyGroups.reset([]);
+ expect(classUnderTest.isActiveGroupInGroupList(emptyGroups)).toBe(false);
+ });
});
});
diff --git a/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.spec.ts b/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.spec.ts
index b893ed25a27..3033eebc99d 100644
--- a/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.spec.ts
+++ b/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.spec.ts
@@ -212,10 +212,11 @@ class MockConfiguratorStorefrontUtilsService {
}
scrollToConfigurationElement(): void {}
-
setFocus(): void {}
-
focusFirstActiveElement(): void {}
+ isCartEntryOrGroupVisited(): Observable {
+ return of(mockGroupVisited);
+ }
}
let component: ConfiguratorGroupMenuComponent;
@@ -312,6 +313,7 @@ describe('ConfiguratorGroupMenuComponent', () => {
configUtils = TestBed.inject(ConfiguratorStorefrontUtilsService);
spyOn(configUtils, 'setFocus').and.stub();
spyOn(configUtils, 'focusFirstActiveElement').and.stub();
+ spyOn(configUtils, 'isCartEntryOrGroupVisited').and.callThrough();
configuratorUtils = TestBed.inject(CommonConfiguratorUtilsService);
configuratorUtils.setOwnerKey(mockProductConfiguration.owner);
@@ -673,7 +675,7 @@ describe('ConfiguratorGroupMenuComponent', () => {
.pipe(take(1))
.subscribe();
- expect(configuratorGroupsService.isGroupVisited).toHaveBeenCalled();
+ expect(configUtils.isCartEntryOrGroupVisited).toHaveBeenCalled();
expect(configuratorGroupsService.isConflictGroupType).toHaveBeenCalled();
});
@@ -742,7 +744,7 @@ describe('ConfiguratorGroupMenuComponent', () => {
);
});
- it('should not return COMPLETE style class if group is complete, consistent and type is CPQ', () => {
+ it('should return COMPLETE style class if group is complete, consistent and type is CPQ', () => {
productConfigurationObservable = of(mockProductConfiguration);
routerStateObservable = of(mockRouterState);
mockGroupVisited = true;
@@ -756,7 +758,9 @@ describe('ConfiguratorGroupMenuComponent', () => {
mockProductConfiguration
)
.pipe(take(1))
- .subscribe((style) => expect(style).toEqual(baseStyleClass));
+ .subscribe((style) =>
+ expect(style).toEqual(baseStyleClass + completeStyleClass)
+ );
});
it('should return WARNING style class if group is inconsistent and type is variant', () => {
@@ -1006,7 +1010,7 @@ describe('ConfiguratorGroupMenuComponent', () => {
);
});
- it("should not contain 'COMPLETE' class despite the group is complete and has been visited but the type is CPQ", () => {
+ it("should contain 'COMPLETE' class because the group is complete, has been visited and the type is CPQ", () => {
clonedSimpleConfig.complete = true;
clonedSimpleConfig.groups[0].complete = true;
clonedSimpleConfig.groups[0].consistent = true;
@@ -1015,7 +1019,7 @@ describe('ConfiguratorGroupMenuComponent', () => {
isConflictGroupType = false;
initialize();
- CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ CommonConfiguratorTestUtilsService.expectElementPresent(
expect,
htmlElem,
'.cx-menu-item.COMPLETE'
@@ -1350,7 +1354,7 @@ describe('ConfiguratorGroupMenuComponent', () => {
});
});
- it('should return appropriate (only inListOfGroups) aria-describedby if group is complete, consistent and type is CPQ', (done) => {
+ it('should return appropriate (ICONSUCCESS) aria-describedby if group is complete, consistent and type is CPQ', (done) => {
clonedProductConfiguration.groups[1].complete = true;
clonedProductConfiguration.groups[1].consistent = true;
clonedProductConfiguration.owner.configuratorType = typeCPQ;
@@ -1363,7 +1367,9 @@ describe('ConfiguratorGroupMenuComponent', () => {
)
.pipe(take(1))
.subscribe((describedby) => {
- expect(describedby.trim()).toEqual('inListOfGroups');
+ expect(describedby.trim()).toEqual(
+ 'ICONSUCCESS1234-56-7892 inListOfGroups'
+ );
done();
});
});
@@ -2034,4 +2040,157 @@ describe('ConfiguratorGroupMenuComponent', () => {
});
});
});
+
+ describe('hasContainerRowSubGroups', () => {
+ it('returns true when a direct child is a container row group', () => {
+ const group: Configurator.Group = {
+ id: 'parent',
+ subGroups: [
+ {
+ id: 'row',
+ groupType: Configurator.GroupType.CONTAINER_ROW_GROUP,
+ subGroups: [],
+ },
+ ],
+ };
+
+ expect(component['hasContainerRowSubGroups'](group)).toBe(true);
+ });
+
+ it('returns false when no container row children exist', () => {
+ const group: Configurator.Group = {
+ id: 'parent',
+ subGroups: [
+ {
+ id: 'child',
+ groupType: Configurator.GroupType.ATTRIBUTE_GROUP,
+ subGroups: [],
+ },
+ ],
+ };
+
+ expect(component['hasContainerRowSubGroups'](group)).toBe(false);
+ });
+ });
+
+ describe('isCondensed', () => {
+ it('returns false when the single child is a container row group', () => {
+ const group: Configurator.Group = {
+ id: 'parent',
+ groupType: Configurator.GroupType.ATTRIBUTE_GROUP,
+ subGroups: [
+ {
+ id: 'row',
+ groupType: Configurator.GroupType.CONTAINER_ROW_GROUP,
+ subGroups: [],
+ },
+ ],
+ };
+
+ expect(component['isCondensed'](group)).toBe(false);
+ });
+
+ it('returns true for a single non-conflict child', () => {
+ const group: Configurator.Group = {
+ id: 'parent',
+ groupType: Configurator.GroupType.ATTRIBUTE_GROUP,
+ subGroups: [
+ {
+ id: 'child',
+ groupType: Configurator.GroupType.ATTRIBUTE_GROUP,
+ subGroups: [],
+ },
+ ],
+ };
+
+ expect(component['isCondensed'](group)).toBe(true);
+ });
+ });
+
+ describe('hasNoAttributes', () => {
+ it('returns true when attributes are missing or empty', () => {
+ expect(component['hasNoAttributes']({ id: 'group', subGroups: [] })).toBe(
+ true
+ );
+ expect(
+ component['hasNoAttributes']({
+ id: 'group',
+ attributes: [],
+ subGroups: [],
+ })
+ ).toBe(true);
+ });
+
+ it('returns false when the group carries attributes', () => {
+ expect(
+ component['hasNoAttributes']({
+ id: 'group',
+ attributes: [{ name: 'attr' }],
+ subGroups: [],
+ })
+ ).toBe(false);
+ });
+ });
+
+ describe('isDialogActive', () => {
+ it('returns true when showConflictSolverDialog is set', () => {
+ expect(
+ component.isDialogActive({
+ interactionState: { showConflictSolverDialog: true },
+ } as Configurator.Configuration)
+ ).toBe(true);
+ });
+
+ it('returns false when showConflictSolverDialog is unset', () => {
+ expect(
+ component.isDialogActive({
+ interactionState: {},
+ } as Configurator.Configuration)
+ ).toBe(false);
+ });
+ });
+
+ describe('createIconId', () => {
+ it('concatenates icon prefix, type and group id', () => {
+ expect(component.createIconId(ICON_TYPE.ERROR, 'group-1')).toBe(
+ 'ICON' + ICON_TYPE.ERROR + 'group-1'
+ );
+ });
+ });
+
+ describe('isConflictHeader', () => {
+ it('returns true for conflict header groups', () => {
+ expect(
+ component.isConflictHeader({
+ groupType: Configurator.GroupType.CONFLICT_HEADER_GROUP,
+ } as Configurator.Group)
+ ).toBe(true);
+ });
+
+ it('returns false for attribute groups', () => {
+ expect(
+ component.isConflictHeader({
+ groupType: Configurator.GroupType.ATTRIBUTE_GROUP,
+ } as Configurator.Group)
+ ).toBe(false);
+ });
+ });
+
+ describe('isConflictGroup', () => {
+ it('returns true for conflict groups', () => {
+ expect(
+ component.isConflictGroup({
+ groupType: Configurator.GroupType.CONFLICT_GROUP,
+ } as Configurator.Group)
+ ).toBe(true);
+ });
+
+ it('returns false for attribute groups', () => {
+ expect(
+ component.isConflictGroup({
+ groupType: Configurator.GroupType.ATTRIBUTE_GROUP,
+ } as Configurator.Group)
+ ).toBe(false);
+ });
+ });
});
diff --git a/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.ts b/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.ts
index 2d3211367c3..895917254d6 100644
--- a/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.ts
+++ b/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.ts
@@ -89,9 +89,7 @@ export class ConfiguratorGroupMenuComponent {
this.configuratorGroupsService.getCurrentGroup(routerData.owner)
)
);
- /**
- * Current parent group. Undefined for top level groups
- */
+
displayedParentGroup$: Observable =
this.configuration$.pipe(
switchMap((configuration) =>
@@ -104,6 +102,10 @@ export class ConfiguratorGroupMenuComponent {
})
);
+ /**
+ * Groups displayed in the menu for the current parent level,
+ * after condensing single-child structural groups.
+ */
displayedGroups$: Observable =
this.displayedParentGroup$.pipe(
switchMap((parentGroup) => {
@@ -138,10 +140,10 @@ export class ConfiguratorGroupMenuComponent {
) {}
/**
- * Selects group or navigates to sub-group depending on clicked group
+ * Selects group or navigates to subgroup depending on clicked group
*
- * @param {Configurator.Group} group - Target Group
- * @param {Configurator.Group} currentGroup - Current group
+ * @param group - Target Group
+ * @param currentGroup - Current group
*/
click(group: Configurator.Group, currentGroup?: Configurator.Group): void {
this.configuration$.pipe(take(1)).subscribe((configuration) => {
@@ -172,7 +174,7 @@ export class ConfiguratorGroupMenuComponent {
/**
* Navigate up and set focus if current group information is provided
*
- * @param {Configurator.Group} currentGroup - Current group
+ * @param currentGroup - Current group
*/
navigateUp(currentGroup?: Configurator.Group): void {
this.displayedParentGroup$
@@ -199,8 +201,8 @@ export class ConfiguratorGroupMenuComponent {
/**
* Retrieves the number of conflicts for the current group.
*
- * @param {Configurator.Group} group - Current group
- * @return {string} - number of conflicts
+ * @param group - Current group
+ * @return - number of conflicts
*/
getConflictNumber(group: Configurator.Group): string {
if (
@@ -215,8 +217,8 @@ export class ConfiguratorGroupMenuComponent {
/**
* Verifies whether the current group has subgroups.
*
- * @param {Configurator.Group} group - Current group
- * @return {boolean} - Returns 'true' if the current group has a subgroups, otherwise 'false'.
+ * @param group - Current group
+ * @return - Returns 'true' if the current group has a subgroups, otherwise 'false'.
*/
hasSubGroups(group: Configurator.Group): boolean {
return this.configuratorGroupsService.hasSubGroups(group);
@@ -257,6 +259,13 @@ export class ConfiguratorGroupMenuComponent {
);
}
+ /**
+ * Retrieves the parent group observable, condensing intermediate levels
+ * when the parent only has a single subgroup in the menu.
+ *
+ * @param parentGroup - Parent group to condense
+ * @returns Observable of the condensed parent group, or `undefined` at root level
+ */
getCondensedParentGroup(
parentGroup: Configurator.Group
): Observable {
@@ -271,6 +280,13 @@ export class ConfiguratorGroupMenuComponent {
}
}
+ /**
+ * Flattens the group hierarchy for display in the menu by replacing
+ * single-child structural groups with their child when appropriate.
+ *
+ * @param groups - Groups to condense
+ * @returns Condensed group list for menu display
+ */
condenseGroups(groups: Configurator.Group[]): Configurator.Group[] {
return groups.flatMap((group) => {
if (this.isCondensed(group)) {
@@ -337,16 +353,16 @@ export class ConfiguratorGroupMenuComponent {
/**
* Returns true if group has been visited and if the group is not a conflict group.
*
- * @param {Configurator.Group} group - Current group
- * @param {Configurator.Configuration} configuration - Configuration
- * @return {Observable} - true if visited and not a conflict group
+ * @param group - Current group
+ * @param configuration - Configuration
+ * @return - true if visited and not a conflict group
*/
isGroupVisited(
group: Configurator.Group,
configuration: Configurator.Configuration
): Observable {
- return this.configuratorGroupsService
- .isGroupVisited(configuration.owner, group.id)
+ return this.configUtils
+ .isCartEntryOrGroupVisited(configuration.owner, group.id)
.pipe(
map(
(isVisited) =>
@@ -362,8 +378,8 @@ export class ConfiguratorGroupMenuComponent {
/**
* Verifies whether the current group is conflict one.
*
- * @param {Configurator.GroupType} groupType - Group type
- * @return {boolean} - 'True' if the current group is conflict one, otherwise 'false'.
+ * @param groupType - Group type
+ * @return - 'True' if the current group is conflict one, otherwise 'false'.
*/
isConflictGroupType(groupType: Configurator.GroupType | undefined): boolean {
return groupType
@@ -374,8 +390,8 @@ export class ConfiguratorGroupMenuComponent {
/**
* Returns true if group is conflict header group.
*
- * @param {Configurator.Group} group - Current group
- * @return {boolean} - Returns 'true' if the current group is conflict header group, otherwise 'false'.
+ * @param group - Current group
+ * @return - Returns 'true' if the current group is conflict header group, otherwise 'false'.
*/
isConflictHeader(group: Configurator.Group): boolean {
return (
@@ -386,8 +402,8 @@ export class ConfiguratorGroupMenuComponent {
/**
* Returns true if group is conflict group.
*
- * @param {Configurator.Group} group - Current group
- * @return {boolean} - Returns 'true' if the current group is conflict group, otherwise 'false'.
+ * @param group - Current group
+ * @return - Returns 'true' if the current group is conflict group, otherwise 'false'.
*/
isConflictGroup(group: Configurator.Group): boolean {
return group && group.groupType === Configurator.GroupType.CONFLICT_GROUP;
@@ -396,9 +412,9 @@ export class ConfiguratorGroupMenuComponent {
/**
* Returns group-status style classes dependent on completeness, conflicts, visited status and configurator type.
*
- * @param {Configurator.Group} group - Current group
- * @param {Configurator.Configuration} configuration - Configuration
- * @return {Observable} - true if visited and not a conflict group
+ * @param group - Current group
+ * @param configuration - Configuration
+ * @returns CSS class names for the group menu item
*/
getGroupStatusStyles(
group: Configurator.Group,
@@ -414,12 +430,7 @@ export class ConfiguratorGroupMenuComponent {
) {
groupStatusStyle = groupStatusStyle + this.WARNING;
}
- if (
- configuration.owner.configuratorType !== CLOUDCPQ_CONFIGURATOR_TYPE &&
- group.complete &&
- group.consistent &&
- isVisited
- ) {
+ if (group.complete && group.consistent && isVisited) {
groupStatusStyle = groupStatusStyle + this.COMPLETE;
}
if (!group.complete && isVisited) {
@@ -441,8 +452,8 @@ export class ConfiguratorGroupMenuComponent {
/**
* Verifies whether the user navigates into a subgroup of the main group menu.
*
- * @param {KeyboardEvent} event - Keyboard event
- * @returns {boolean} -'true' if the user navigates into the subgroup, otherwise 'false'.
+ * @param event - Keyboard event
+ * @returns -'true' if the user navigates into the subgroup, otherwise 'false'.
* @protected
*/
protected isForwardsNavigation(event: KeyboardEvent): boolean {
@@ -455,8 +466,8 @@ export class ConfiguratorGroupMenuComponent {
/**
* Verifies whether the user navigates from a subgroup back to the main group menu.
*
- * @param {KeyboardEvent} event - Keyboard event
- * @returns {boolean} -'true' if the user navigates back into the main group menu, otherwise 'false'.
+ * @param event - Keyboard event
+ * @returns -'true' if the user navigates back into the main group menu, otherwise 'false'.
* @protected
*/
protected isBackNavigation(event: KeyboardEvent): boolean {
@@ -469,10 +480,10 @@ export class ConfiguratorGroupMenuComponent {
/**
* Switches the group on pressing an arrow key.
*
- * @param {KeyboardEvent} event - Keyboard event
- * @param {string} groupIndex - Group index
- * @param {Configurator.Group} targetGroup - Target group
- * @param {Configurator.Group} currentGroup - Current group
+ * @param event - Keyboard event
+ * @param groupIndex - Group index
+ * @param targetGroup - Target group
+ * @param currentGroup - Current group
*/
switchGroupOnArrowPress(
event: KeyboardEvent,
@@ -504,7 +515,7 @@ export class ConfiguratorGroupMenuComponent {
* Only if the active group is not in the list of displayed groups, the focus should be set to the first element of the menu ('X') otherwise
* the focus is set to the active group menu item.
*
- * @param {KeyboardEvent} event - Keyboard event
+ * @param event - Keyboard event
*/
protected handleFocusLoopInMobileMode(event: KeyboardEvent): void {
this.breakpointService
@@ -531,7 +542,7 @@ export class ConfiguratorGroupMenuComponent {
* Persists the keyboard focus state for the given key
* from the main group menu by back navigation.
*
- * @param {string} currentGroupId - Current group ID
+ * @param currentGroupId - Current group ID
*/
setFocusForMainMenu(currentGroupId?: string): void {
let key: string | undefined = currentGroupId;
@@ -553,8 +564,8 @@ export class ConfiguratorGroupMenuComponent {
* Persists the keyboard focus state for the given key
* from the subgroup menu by forwards navigation.
*
- * @param {Configurator.Group} group - Group
- * @param {string} currentGroupId - Current group ID
+ * @param group - Group
+ * @param currentGroupId - Current group ID
*/
setFocusForSubGroup(
group: Configurator.Group,
@@ -570,9 +581,9 @@ export class ConfiguratorGroupMenuComponent {
/**
* Verifies whether the parent group contains a selected group.
*
- * @param {Configurator.Group} group - Group
- * @param {string} currentGroupId - Current group ID
- * @returns {boolean} - 'true' if the parent group contains a selected group, otherwise 'false'
+ * @param group - Group
+ * @param currentGroupId - Current group ID
+ * @returns - 'true' if the parent group contains a selected group, otherwise 'false'
*/
containsSelectedGroup(
group: Configurator.Group,
@@ -586,12 +597,12 @@ export class ConfiguratorGroupMenuComponent {
}
/**
- * Retrieves the tab index depending on if the the current group is selected
+ * Retrieves the tab index depending on if the current group is selected
* or the parent group contains the selected group.
*
- * @param {Configurator.Group} group - Group
- * @param {string} currentGroupId - Current group ID
- * @returns {number} - tab index
+ * @param group - Group
+ * @param currentGroupId - Current group ID
+ * @returns - tab index
*/
getTabIndex(group: Configurator.Group, currentGroupId: string): number {
const isCurrentGroupPartOfGroupHierarchy =
@@ -603,9 +614,9 @@ export class ConfiguratorGroupMenuComponent {
/**
* Verifies whether the current group is selected.
*
- * @param {string} groupId - group ID
- * @param {string} currentGroupId - Current group ID
- * @returns {boolean} - 'true' if the current group is selected, otherwise 'false'
+ * @param groupId - group ID
+ * @param currentGroupId - Current group ID
+ * @returns - 'true' if the current group is selected, otherwise 'false'
*/
isGroupSelected(groupId?: string, currentGroupId?: string): boolean {
return groupId === currentGroupId;
@@ -614,8 +625,8 @@ export class ConfiguratorGroupMenuComponent {
/**
* Generates a group ID for aria-controls.
*
- * @param {string} groupId - group ID
- * @returns {string | undefined} - generated group ID
+ * @param groupId - group ID
+ * @returns - generated group ID
*/
createAriaControls(groupId?: string): string | undefined {
return this.configUtils.createGroupId(groupId);
@@ -624,8 +635,8 @@ export class ConfiguratorGroupMenuComponent {
/**
* Generates aria-label for group menu item
*
- * @param {Configurator.Group} group - group
- * @returns {string | undefined} - generated group ID
+ * @param group - Group
+ * @returns Translated aria-label for the group menu item
*/
getAriaLabel(group: Configurator.Group): string {
let translatedText = '';
@@ -654,9 +665,9 @@ export class ConfiguratorGroupMenuComponent {
/**
* Generates an id for icons.
*
- * @param {ICON_TYPE} type - icon type
- * @param {string} groupId - group id
- * @returns {string | undefined} - generated icon id
+ * @param type - icon type
+ * @param groupId - group id
+ * @returns - generated icon id
*/
createIconId(type: ICON_TYPE, groupId?: string): string | undefined {
return this.ICON + type + groupId;
@@ -665,9 +676,9 @@ export class ConfiguratorGroupMenuComponent {
/**
* Generates aria-describedby
*
- * @param {Configurator.Group} group - Current group
- * @param {Configurator.Configuration} configuration - Configuration
- * @return {Observable} - aria-describedby
+ * @param group - Current group
+ * @param configuration - Configuration
+ * @return - aria-describedby
*/
getAriaDescribedby(
group: Configurator.Group,
@@ -686,12 +697,7 @@ export class ConfiguratorGroupMenuComponent {
ariaDescribedby =
ariaDescribedby + this.createIconId(ICON_TYPE.WARNING, group.id);
}
- if (
- configuration.owner.configuratorType !== CLOUDCPQ_CONFIGURATOR_TYPE &&
- group.complete &&
- group.consistent &&
- isVisited
- ) {
+ if (group.complete && group.consistent && isVisited) {
ariaDescribedby =
ariaDescribedby +
' ' +
@@ -715,6 +721,13 @@ export class ConfiguratorGroupMenuComponent {
);
}
+ /**
+ * Returns the title shown for a group menu item. Includes the technical
+ * group name when expert mode is active, except for conflict groups.
+ *
+ * @param group - Group to display
+ * @returns Group menu title
+ */
getGroupMenuTitle(group: Configurator.Group): string | undefined {
let title = group.description;
if (!this.isConflictHeader(group) && !this.isConflictGroup(group)) {
@@ -730,6 +743,12 @@ export class ConfiguratorGroupMenuComponent {
return title;
}
+ /**
+ * Determines whether a group menu item should be shown.
+ *
+ * @param group - Group to check
+ * @returns Observable that emits `true` when the menu item is visible
+ */
displayMenuItem(group: Configurator.Group): Observable {
return this.configuration$.pipe(
map((configuration) => {
@@ -746,9 +765,10 @@ export class ConfiguratorGroupMenuComponent {
}
/**
- * Checks if conflict solver dialog is active
- * @param configuration
- * @returns Conflict solver dialog active?
+ * Checks if conflict solver dialog is active.
+ *
+ * @param configuration - Configuration
+ * @returns - Conflict solver dialog active?
*/
isDialogActive(configuration: Configurator.Configuration): boolean {
return configuration.interactionState.showConflictSolverDialog ?? false;
@@ -757,6 +777,7 @@ export class ConfiguratorGroupMenuComponent {
/**
* track-by function for the *ngFor generating the group menu,
* returning the group id
+ *
* @param _index
* @param group
* @returns groupId
diff --git a/feature-libs/product-configurator/rulebased/components/group/configurator-group.component.spec.ts b/feature-libs/product-configurator/rulebased/components/group/configurator-group.component.spec.ts
index c6f764b5767..32270b06886 100644
--- a/feature-libs/product-configurator/rulebased/components/group/configurator-group.component.spec.ts
+++ b/feature-libs/product-configurator/rulebased/components/group/configurator-group.component.spec.ts
@@ -58,6 +58,9 @@ import { ConfiguratorPriceComponentOptions } from '../price/configurator-price.c
import { ConfiguratorStorefrontUtilsService } from '../service/configurator-storefront-utils.service';
import { ConfiguratorGroupComponent } from './configurator-group.component';
+const config: Configurator.Configuration =
+ ConfigurationTestData.productConfiguration;
+
const PRODUCT_CODE = 'CONF_LAPTOP';
const OWNER = ConfiguratorModelUtils.createOwner(
@@ -153,6 +156,10 @@ let currentGroupObservable: Observable = EMPTY;
let isConfigurationLoadingObservable: Observable = EMPTY;
class MockConfiguratorCommonsService {
+ getConfiguration(): Observable {
+ return of(config);
+ }
+
removeConfiguration(): void {}
updateConfiguration(): void {}
diff --git a/feature-libs/product-configurator/rulebased/components/index.ts b/feature-libs/product-configurator/rulebased/components/index.ts
index f95bdf19c01..10dff4c7f3c 100644
--- a/feature-libs/product-configurator/rulebased/components/index.ts
+++ b/feature-libs/product-configurator/rulebased/components/index.ts
@@ -16,6 +16,7 @@ export * from './group/index';
export * from './form/index';
export * from './group-menu/index';
export * from './group-title/index';
+export * from './message/index';
export * from './overview-attribute/index';
export * from './overview-bundle-attribute/index';
export * from './overview-filter/index';
diff --git a/feature-libs/product-configurator/rulebased/components/message/configurator-message.component.html b/feature-libs/product-configurator/rulebased/components/message/configurator-message.component.html
new file mode 100644
index 00000000000..b43f6158254
--- /dev/null
+++ b/feature-libs/product-configurator/rulebased/components/message/configurator-message.component.html
@@ -0,0 +1,12 @@
+
+
+ {{ isString(message) ? message : (message | cxTranslate) }}
+
diff --git a/feature-libs/product-configurator/rulebased/components/message/configurator-message.component.spec.ts b/feature-libs/product-configurator/rulebased/components/message/configurator-message.component.spec.ts
new file mode 100644
index 00000000000..29784523bc4
--- /dev/null
+++ b/feature-libs/product-configurator/rulebased/components/message/configurator-message.component.spec.ts
@@ -0,0 +1,223 @@
+import { ChangeDetectionStrategy } from '@angular/core';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { I18nTestingModule } from '@spartacus/core';
+import { ICON_TYPE, IconLoaderService } from '@spartacus/storefront';
+import { CommonConfiguratorTestUtilsService } from '../../../common/testing/common-configurator-test-utils.service';
+import { ConfiguratorMessageComponent } from './configurator-message.component';
+
+class MockIconFontLoaderService {
+ useSvg(_iconType: ICON_TYPE) {
+ return false;
+ }
+
+ getStyleClasses(_iconType: ICON_TYPE): string {
+ return 'fas fa-exclamation-circle';
+ }
+
+ addLinkResource() {}
+ getHtml(_iconType: ICON_TYPE) {}
+ getFlipDirection(): void {}
+}
+
+describe('ConfiguratorMessageComponent', () => {
+ let component: ConfiguratorMessageComponent;
+ let fixture: ComponentFixture;
+ let htmlElem: HTMLElement;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [ConfiguratorMessageComponent, I18nTestingModule],
+ providers: [
+ { provide: IconLoaderService, useClass: MockIconFontLoaderService },
+ ],
+ })
+ .overrideComponent(ConfiguratorMessageComponent, {
+ set: {
+ changeDetection: ChangeDetectionStrategy.Default,
+ },
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ConfiguratorMessageComponent);
+ component = fixture.componentInstance;
+ htmlElem = fixture.nativeElement;
+ component.messages = ['First message', 'Second message'];
+ component.messageClass = 'cx-error-message';
+ component.iconType = ICON_TYPE.ERROR;
+ component.showIcon = true;
+ component.idPrefix = 'cx-configurator--row-error-msg--888';
+ component.role = 'alert';
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('template rendering', () => {
+ describe('when messages are missing', () => {
+ it('renders no rows when messages are undefined', () => {
+ component.messages = undefined;
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ 'div'
+ );
+ });
+
+ it('renders no rows when messages are empty', () => {
+ component.messages = [];
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ 'div'
+ );
+ });
+ });
+
+ describe('when messages are provided', () => {
+ it('renders one row per message with messageClass and text', () => {
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectNumberOfElementsPresent(
+ expect,
+ htmlElem,
+ '.cx-error-message',
+ 2
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-error-message',
+ 'First message'
+ );
+ CommonConfiguratorTestUtilsService.expectElementToContainText(
+ expect,
+ htmlElem,
+ '.cx-error-message',
+ 'Second message',
+ 1
+ );
+ });
+
+ it('renders cx-icon when showIcon is true', () => {
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementPresent(
+ expect,
+ htmlElem,
+ 'cx-icon'
+ );
+ });
+
+ it('omits cx-icon when showIcon is false', () => {
+ component.showIcon = false;
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementNotPresent(
+ expect,
+ htmlElem,
+ 'cx-icon'
+ );
+ });
+
+ it('resolves translatable messages via the translation pipe', () => {
+ component.messages = [
+ { key: 'configurator.attribute.containerMinRows' },
+ ];
+ component.messageClass = 'container-info-message';
+ fixture.detectChanges();
+
+ CommonConfiguratorTestUtilsService.expectElementPresent(
+ expect,
+ htmlElem,
+ '.container-info-message'
+ );
+ });
+ });
+ });
+
+ describe('getMessageId', () => {
+ it('appends index to idPrefix', () => {
+ expect(component.getMessageId(0)).toBe(
+ 'cx-configurator--row-error-msg--888-0'
+ );
+ expect(component.getMessageId(1)).toBe(
+ 'cx-configurator--row-error-msg--888-1'
+ );
+ });
+
+ it('returns undefined without idPrefix', () => {
+ component.idPrefix = undefined;
+ expect(component.getMessageId(0)).toBeUndefined();
+ });
+ });
+
+ describe('accessibility', () => {
+ beforeEach(() => {
+ fixture.detectChanges();
+ });
+
+ it('sets aria-live, aria-atomic, role, aria-label and id on each row', () => {
+ CommonConfiguratorTestUtilsService.expectElementContainsA11y(
+ expect,
+ htmlElem,
+ 'div',
+ 'cx-error-message',
+ 0,
+ 'aria-live',
+ 'assertive'
+ );
+ CommonConfiguratorTestUtilsService.expectElementContainsA11y(
+ expect,
+ htmlElem,
+ 'div',
+ 'cx-error-message',
+ 0,
+ 'aria-atomic',
+ 'true'
+ );
+ CommonConfiguratorTestUtilsService.expectElementContainsA11y(
+ expect,
+ htmlElem,
+ 'div',
+ 'cx-error-message',
+ 0,
+ 'role',
+ 'alert'
+ );
+ CommonConfiguratorTestUtilsService.expectElementContainsA11y(
+ expect,
+ htmlElem,
+ 'div',
+ 'cx-error-message',
+ 0,
+ 'aria-label',
+ 'First message'
+ );
+ CommonConfiguratorTestUtilsService.expectElementContainsA11y(
+ expect,
+ htmlElem,
+ 'div',
+ 'cx-error-message',
+ 0,
+ 'id',
+ 'cx-configurator--row-error-msg--888-0'
+ );
+ CommonConfiguratorTestUtilsService.expectElementContainsA11y(
+ expect,
+ htmlElem,
+ 'div',
+ 'cx-error-message',
+ 1,
+ 'id',
+ 'cx-configurator--row-error-msg--888-1'
+ );
+ });
+ });
+});
diff --git a/feature-libs/product-configurator/rulebased/components/message/configurator-message.component.ts b/feature-libs/product-configurator/rulebased/components/message/configurator-message.component.ts
new file mode 100644
index 00000000000..8ae2cb6e75c
--- /dev/null
+++ b/feature-libs/product-configurator/rulebased/components/message/configurator-message.component.ts
@@ -0,0 +1,75 @@
+/*
+ * SPDX-FileCopyrightText: 2026 SAP Spartacus team
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { NgClass, NgFor, NgIf } from '@angular/common';
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { TranslatePipe } from '@spartacus/core';
+import { ICON_TYPE, IconComponent } from '@spartacus/storefront';
+import { ConfiguratorTranslatableMessage } from '../service/configurator-message.service';
+
+/**
+ * Presentational component that renders a list of messages,
+ * each optionally with a severity icon and message text. Consumers pass styling,
+ * icon type, icon visibility, ARIA role, and an optional id prefix so the same
+ * component can be reused on product cards, attribute headers, and similar surfaces.
+ */
+@Component({
+ selector: 'cx-configurator-message',
+ templateUrl: './configurator-message.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [NgIf, NgFor, NgClass, IconComponent, TranslatePipe],
+})
+export class ConfiguratorMessageComponent {
+ /**
+ * Messages to display, in render order. Plain strings are shown as-is;
+ * {@link Translatable} entries are resolved through the translation pipe.
+ * Callers merge their plain and translatable messages into this single list.
+ * Empty or undefined lists render nothing.
+ */
+ @Input() messages?: ConfiguratorTranslatableMessage[];
+ /**
+ * CSS class applied to each message row.
+ */
+ @Input() messageClass?: string;
+ /**
+ * Icon representing the message severity.
+ */
+ @Input() iconType?: ICON_TYPE;
+ /**
+ * Whether the severity icon is displayed.
+ */
+ @Input() showIcon = false;
+ /**
+ * Prefix used to build a unique id for each message row. The row index
+ * is appended as a suffix.
+ */
+ @Input() idPrefix?: string;
+ /**
+ * Optional ARIA role, for example `alert` for errors.
+ */
+ @Input() role?: string;
+
+ /**
+ * Type guard used by the template to decide whether a message must be
+ * translated. Plain strings are already resolved text and rendered as-is.
+ *
+ * @param message - Message to inspect
+ * @returns `true` if the message is a plain string
+ */
+ isString(message: ConfiguratorTranslatableMessage): message is string {
+ return typeof message === 'string';
+ }
+
+ /**
+ * Builds a unique id for a message row.
+ *
+ * @param index - Zero-based index of the message in the list
+ * @returns Id, or `undefined` if no prefix is provided
+ */
+ getMessageId(index: number): string | undefined {
+ return this.idPrefix ? `${this.idPrefix}-${index}` : undefined;
+ }
+}
diff --git a/feature-libs/product-configurator/rulebased/components/message/configurator-message.module.ts b/feature-libs/product-configurator/rulebased/components/message/configurator-message.module.ts
new file mode 100644
index 00000000000..653bfa053e0
--- /dev/null
+++ b/feature-libs/product-configurator/rulebased/components/message/configurator-message.module.ts
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: 2026 SAP Spartacus team
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { IconModule } from '@spartacus/storefront';
+import { ConfiguratorMessageComponent } from './configurator-message.component';
+
+@NgModule({
+ imports: [CommonModule, IconModule, ConfiguratorMessageComponent],
+ exports: [ConfiguratorMessageComponent],
+})
+export class ConfiguratorMessageModule {}
diff --git a/feature-libs/product-configurator/rulebased/components/message/index.ts b/feature-libs/product-configurator/rulebased/components/message/index.ts
new file mode 100644
index 00000000000..bbcf29b3b10
--- /dev/null
+++ b/feature-libs/product-configurator/rulebased/components/message/index.ts
@@ -0,0 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: 2026 SAP Spartacus team
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export * from './configurator-message.component';
+export * from './configurator-message.module';
diff --git a/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.spec.ts b/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.spec.ts
index 0bb4eee1553..d667491f7f5 100644
--- a/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.spec.ts
+++ b/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.spec.ts
@@ -320,4 +320,25 @@ describe('ConfigOverviewNotificationBannerComponent', () => {
expect(skipConflicts).toBe(false)
);
});
+
+ it('should count CPQ overview issues including nested container-row issues', () => {
+ const cpqConfigurationWithNestedIssues: Configurator.Configuration = {
+ ...productConfigurationWithoutIssues,
+ overview: {
+ configId: CONFIG_ID,
+ productCode: productConfigurationWithoutIssues.productCode ?? '',
+ totalNumberOfIssues: 6,
+ },
+ };
+ configurationObs = of(cpqConfigurationWithNestedIssues);
+ initialize(routerData);
+ component.numberOfIssues$.subscribe((numberOfIssues) =>
+ expect(numberOfIssues).toBe(6)
+ );
+ CommonConfiguratorTestUtilsService.expectElementPresent(
+ expect,
+ htmlElem,
+ '.cx-error-msg'
+ );
+ });
});
diff --git a/feature-libs/product-configurator/rulebased/components/service/configurator-message.service.spec.ts b/feature-libs/product-configurator/rulebased/components/service/configurator-message.service.spec.ts
new file mode 100644
index 00000000000..2f8927ea107
--- /dev/null
+++ b/feature-libs/product-configurator/rulebased/components/service/configurator-message.service.spec.ts
@@ -0,0 +1,249 @@
+/*
+ * SPDX-FileCopyrightText: 2026 SAP Spartacus team
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { ICON_TYPE } from '@spartacus/storefront';
+import { Configurator } from '../../core/model/configurator.model';
+import {
+ ConfiguratorMessageService,
+ ConfiguratorMessagesView,
+} from './configurator-message.service';
+
+describe('ConfiguratorMessageService', () => {
+ let service: ConfiguratorMessageService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(ConfiguratorMessageService);
+ });
+
+ it('should create', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('splitMessagesBySeverity', () => {
+ it('returns empty buckets when messages are undefined', () => {
+ expect(service.splitMessagesBySeverity()).toEqual({
+ infoMessages: [],
+ errorMessages: [],
+ warningMessages: [],
+ });
+ });
+
+ it('maps ERROR, WARNING and INFO to separate buckets', () => {
+ expect(
+ service.splitMessagesBySeverity([
+ {
+ message: 'Too many units',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ {
+ message: 'Check quantity',
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ {
+ message: 'Invalid configuration',
+ severity: Configurator.MessageSeverity.ERROR,
+ },
+ ])
+ ).toEqual({
+ infoMessages: ['Check quantity'],
+ warningMessages: ['Too many units'],
+ errorMessages: ['Invalid configuration'],
+ });
+ });
+
+ it('treats missing severity as info', () => {
+ expect(
+ service.splitMessagesBySeverity([{ message: 'Unspecified message' }])
+ ).toEqual({
+ infoMessages: ['Unspecified message'],
+ errorMessages: [],
+ warningMessages: [],
+ });
+ });
+ });
+
+ describe('enrichMessagesWithContainerContext', () => {
+ it('adds container info and required messages when include flags are true', () => {
+ expect(
+ service.enrichMessagesWithContainerContext(
+ {
+ infoMessages: ['Info'],
+ warningMessages: [],
+ errorMessages: ['Error'],
+ },
+ {
+ minRows: 2,
+ maxRows: 4,
+ rows: [],
+ includeContainerInfo: true,
+ includeRequiredError: true,
+ getContainerRowInfoKey: () => ({
+ key: 'configurator.attribute.containerMinMaxRows',
+ params: { minRows: 2, maxRows: 4 },
+ }),
+ getContainerRequiredMessageKey: () => ({
+ key: 'configurator.attribute.containerRequiredMessage',
+ params: { count: 2 },
+ }),
+ }
+ )
+ ).toEqual({
+ infoMessages: ['Info'],
+ warningMessages: [],
+ errorMessages: ['Error'],
+ containerInfoMessages: [
+ {
+ key: 'configurator.attribute.containerMinMaxRows',
+ params: { minRows: 2, maxRows: 4 },
+ },
+ ],
+ requiredErrorMessages: [
+ {
+ key: 'configurator.attribute.containerRequiredMessage',
+ params: { count: 2 },
+ },
+ ],
+ });
+ });
+
+ it('skips container info when includeContainerInfo is false', () => {
+ expect(
+ service.enrichMessagesWithContainerContext(
+ {
+ infoMessages: ['Info'],
+ warningMessages: [],
+ errorMessages: ['Error'],
+ containerInfoMessages: [
+ {
+ key: 'configurator.attribute.containerMinRows',
+ params: { count: 2 },
+ },
+ ],
+ },
+ {
+ minRows: 2,
+ rows: [],
+ includeContainerInfo: false,
+ includeRequiredError: true,
+ getContainerRowInfoKey: () => ({
+ key: 'configurator.attribute.containerMinMaxRows',
+ params: { minRows: 2, maxRows: 4 },
+ }),
+ getContainerRequiredMessageKey: () => ({
+ key: 'configurator.attribute.containerRequiredMessage',
+ params: { count: 1 },
+ }),
+ }
+ )
+ ).toEqual({
+ infoMessages: ['Info'],
+ warningMessages: [],
+ errorMessages: ['Error'],
+ containerInfoMessages: [
+ {
+ key: 'configurator.attribute.containerMinRows',
+ params: { count: 2 },
+ },
+ ],
+ requiredErrorMessages: [
+ {
+ key: 'configurator.attribute.containerRequiredMessage',
+ params: { count: 1 },
+ },
+ ],
+ });
+ });
+ });
+
+ describe('filterMessagesByProductSelection', () => {
+ const view: ConfiguratorMessagesView = {
+ infoMessages: ['Info'],
+ warningMessages: ['Warning'],
+ errorMessages: ['Error'],
+ containerInfoMessages: [
+ {
+ key: 'configurator.attribute.containerMinRows',
+ params: { count: 2 },
+ },
+ ],
+ requiredErrorMessages: [
+ {
+ key: 'configurator.attribute.containerRequiredMessage',
+ params: { count: 1 },
+ },
+ ],
+ };
+
+ it('keeps engine errors only and clears info, warning and container context for selected products', () => {
+ expect(service.filterMessagesByProductSelection(view, true)).toEqual({
+ infoMessages: [],
+ warningMessages: [],
+ errorMessages: ['Error'],
+ containerInfoMessages: [],
+ requiredErrorMessages: [],
+ });
+ });
+
+ it('keeps info, warning and container context and clears engine errors for unselected products', () => {
+ expect(service.filterMessagesByProductSelection(view, false)).toEqual({
+ infoMessages: ['Info'],
+ warningMessages: ['Warning'],
+ errorMessages: [],
+ containerInfoMessages: [
+ {
+ key: 'configurator.attribute.containerMinRows',
+ params: { count: 2 },
+ },
+ ],
+ requiredErrorMessages: [
+ {
+ key: 'configurator.attribute.containerRequiredMessage',
+ params: { count: 1 },
+ },
+ ],
+ });
+ });
+ });
+
+ describe('prependContainerContextMessageGroups', () => {
+ it('places container info and required groups before severity groups', () => {
+ const groups = service.prependContainerContextMessageGroups(
+ {
+ infoMessages: [],
+ warningMessages: [],
+ errorMessages: ['Error'],
+ containerInfoMessages: [
+ {
+ key: 'configurator.attribute.containerMinRows',
+ params: { count: 2 },
+ },
+ ],
+ requiredErrorMessages: [
+ {
+ key: 'configurator.attribute.containerRequiredMessage',
+ params: { count: 1 },
+ },
+ ],
+ },
+ {
+ containerInfoMessageClass: 'info',
+ requiredErrorMessageClass: 'required',
+ iconTypeError: ICON_TYPE.ERROR,
+ containerInfoUiKeyPrefix: 'container-info-msg',
+ requiredErrorUiKeyPrefix: 'required-msg',
+ }
+ );
+
+ expect(groups.map((group) => group.uiKeyPrefix)).toEqual([
+ 'container-info-msg',
+ 'required-msg',
+ 'error-msg',
+ ]);
+ });
+ });
+});
diff --git a/feature-libs/product-configurator/rulebased/components/service/configurator-message.service.ts b/feature-libs/product-configurator/rulebased/components/service/configurator-message.service.ts
new file mode 100644
index 00000000000..48fe0abf152
--- /dev/null
+++ b/feature-libs/product-configurator/rulebased/components/service/configurator-message.service.ts
@@ -0,0 +1,254 @@
+/*
+ * SPDX-FileCopyrightText: 2026 SAP Spartacus team
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Injectable } from '@angular/core';
+import { Translatable } from '@spartacus/core';
+import { ICON_TYPE } from '@spartacus/storefront';
+import { Configurator } from '../../core/model/configurator.model';
+
+/** Message rendered via the translation pipe. */
+export type ConfiguratorTranslatableMessage = string | Translatable;
+
+/**
+ * View model of configurator messages grouped by severity.
+ */
+export interface ConfiguratorMessagesView {
+ infoMessages: string[];
+ warningMessages: string[];
+ errorMessages: string[];
+ /** Container min/max row info shown before other info messages. */
+ containerInfoMessages?: Translatable[];
+ /** Required container message shown before other error messages. */
+ requiredErrorMessages?: Translatable[];
+}
+
+export interface ConfiguratorMessageGroup {
+ /**
+ * Messages of this severity. Plain strings are rendered as-is, while
+ * {@link Translatable} entries are resolved through the translation pipe.
+ */
+ messages: ConfiguratorTranslatableMessage[];
+ /** CSS class applied to the message row. */
+ messageClass: string;
+ /** Icon representing the message severity. */
+ iconType?: ICON_TYPE;
+ /** Whether the severity icon is displayed. */
+ showIcon: boolean;
+ /** Prefix used to build a unique UI key for the message row. */
+ uiKeyPrefix: string;
+ /** Optional ARIA role, for example `alert` for errors. */
+ role?: string;
+}
+
+/**
+ * Context used to prepend container min/max info and required messages.
+ */
+export interface ConfiguratorContainerMessagesContext {
+ minRows?: number;
+ maxRows?: number;
+ rows?: Configurator.ContainerRow[];
+ includeContainerInfo?: boolean;
+ includeRequiredError?: boolean;
+ getContainerRowInfoKey: (
+ minRows?: number,
+ maxRows?: number
+ ) => Translatable | undefined;
+ getContainerRequiredMessageKey: (
+ minRows?: number,
+ rows?: Configurator.ContainerRow[]
+ ) => Translatable | undefined;
+}
+
+/**
+ * Reusable service that groups, merges, filters and enriches configurator
+ * messages so that the logic can be shared by any component that renders
+ * configurator messages.
+ */
+@Injectable({ providedIn: 'root' })
+export class ConfiguratorMessageService {
+ /**
+ * Splits configurator messages into severity buckets.
+ * A message without severity is treated like `info`.
+ *
+ * @param messages - Messages by the configuration engine
+ * @returns Messages grouped by severity
+ */
+ splitMessagesBySeverity(
+ messages?: Configurator.Message[]
+ ): ConfiguratorMessagesView {
+ const infoMessages: string[] = [];
+ const warningMessages: string[] = [];
+ const errorMessages: string[] = [];
+ messages?.forEach((message) => {
+ switch (message.severity) {
+ case Configurator.MessageSeverity.ERROR:
+ errorMessages.push(message.message);
+ break;
+ case Configurator.MessageSeverity.WARNING:
+ warningMessages.push(message.message);
+ break;
+ case Configurator.MessageSeverity.INFO:
+ infoMessages.push(message.message);
+ break;
+ default:
+ infoMessages.push(message.message);
+ break;
+ }
+ });
+ return { infoMessages, warningMessages, errorMessages };
+ }
+
+ /**
+ * Filters messages for a container product card by selection state.
+ * Selected products show error messages,
+ * unselected products show info, error, container relevant and required messages.
+ *
+ * @param view - Messages grouped by severity
+ * @param selected - Whether the product card is selected
+ * @returns Messages applicable to the product selection state
+ */
+ filterMessagesByProductSelection(
+ view: ConfiguratorMessagesView,
+ selected: boolean
+ ): ConfiguratorMessagesView {
+ if (selected) {
+ return {
+ ...view,
+ infoMessages: [],
+ warningMessages: [],
+ containerInfoMessages: [],
+ requiredErrorMessages: [],
+ };
+ }
+
+ return {
+ ...view,
+ errorMessages: [],
+ };
+ }
+
+ /**
+ * Adds container min/max info and required messages to a severity view.
+ * Both are kept separate so callers can render them before engine messages.
+ *
+ * @param view - Messages grouped by severity
+ * @param context - Container context and helper callbacks
+ * @returns View enriched with container info and required messages
+ */
+ enrichMessagesWithContainerContext(
+ view: ConfiguratorMessagesView,
+ context: ConfiguratorContainerMessagesContext
+ ): ConfiguratorMessagesView {
+ const enriched: ConfiguratorMessagesView = { ...view };
+ if (context.includeContainerInfo) {
+ const containerInfo = context.getContainerRowInfoKey(
+ context.minRows,
+ context.maxRows
+ );
+ if (containerInfo) {
+ enriched.containerInfoMessages = [
+ ...(view.containerInfoMessages ?? []),
+ containerInfo,
+ ];
+ }
+ }
+
+ if (context.includeRequiredError) {
+ const requiredInfo = context.getContainerRequiredMessageKey(
+ context.minRows,
+ context.rows
+ );
+ if (requiredInfo) {
+ enriched.requiredErrorMessages = [
+ ...(view.requiredErrorMessages ?? []),
+ requiredInfo,
+ ];
+ }
+ }
+
+ return enriched;
+ }
+
+ /**
+ * Builds severity based message groups (info, error, warning) from a
+ * message view, keeping only the groups that actually contain messages.
+ *
+ * @param messages - Messages grouped by severity
+ * @returns Non-empty severity message groups
+ */
+ buildSeverityMessageGroups(
+ messages: ConfiguratorMessagesView
+ ): ConfiguratorMessageGroup[] {
+ return [
+ {
+ messages: messages.infoMessages,
+ messageClass: 'cx-info-msg',
+ showIcon: false,
+ uiKeyPrefix: 'info-msg',
+ },
+ {
+ messages: messages.errorMessages,
+ messageClass: 'cx-error-msg',
+ iconType: ICON_TYPE.ERROR,
+ showIcon: true,
+ uiKeyPrefix: 'error-msg',
+ role: 'alert',
+ },
+ {
+ messages: messages.warningMessages,
+ messageClass: 'cx-warning-msg',
+ iconType: ICON_TYPE.WARNING,
+ showIcon: true,
+ uiKeyPrefix: 'warning-msg',
+ },
+ ].filter((group) => group.messages.length > 0);
+ }
+
+ /**
+ * Builds the severity message groups from the given view and prepends the
+ * container info and required message groups before them.
+ *
+ * @param messagesView - View that may contain container context messages view
+ * @param options - Styling and icon configuration for the prepended messageGroups
+ * @returns Groups with container context messagesView first, followed by the
+ * severity based message groups
+ */
+ prependContainerContextMessageGroups(
+ messagesView: ConfiguratorMessagesView,
+ options: {
+ containerInfoMessageClass: string;
+ requiredErrorMessageClass: string;
+ iconTypeError: ICON_TYPE;
+ containerInfoUiKeyPrefix: string;
+ requiredErrorUiKeyPrefix: string;
+ }
+ ): ConfiguratorMessageGroup[] {
+ const messageGroups = this.buildSeverityMessageGroups(messagesView);
+ const prependedGroups: ConfiguratorMessageGroup[] = [];
+
+ if (messagesView.containerInfoMessages?.length) {
+ prependedGroups.push({
+ messages: messagesView.containerInfoMessages,
+ messageClass: options.containerInfoMessageClass,
+ showIcon: false,
+ uiKeyPrefix: options.containerInfoUiKeyPrefix,
+ });
+ }
+
+ if (messagesView.requiredErrorMessages?.length) {
+ prependedGroups.push({
+ messages: messagesView.requiredErrorMessages,
+ messageClass: options.requiredErrorMessageClass,
+ iconType: options.iconTypeError,
+ showIcon: true,
+ uiKeyPrefix: options.requiredErrorUiKeyPrefix,
+ role: 'alert',
+ });
+ }
+
+ return [...prependedGroups, ...messageGroups];
+ }
+}
diff --git a/feature-libs/product-configurator/rulebased/components/service/index.ts b/feature-libs/product-configurator/rulebased/components/service/index.ts
index 7110f6f8ea2..187dad22964 100644
--- a/feature-libs/product-configurator/rulebased/components/service/index.ts
+++ b/feature-libs/product-configurator/rulebased/components/service/index.ts
@@ -4,4 +4,5 @@
* SPDX-License-Identifier: Apache-2.0
*/
+export * from './configurator-message.service';
export * from './configurator-storefront-utils.service';
diff --git a/feature-libs/product-configurator/rulebased/core/facade/configurator-group-status.service.spec.ts b/feature-libs/product-configurator/rulebased/core/facade/configurator-group-status.service.spec.ts
index fce670da813..290ffefd9e0 100644
--- a/feature-libs/product-configurator/rulebased/core/facade/configurator-group-status.service.spec.ts
+++ b/feature-libs/product-configurator/rulebased/core/facade/configurator-group-status.service.spec.ts
@@ -4,6 +4,7 @@ import { Store, StoreModule } from '@ngrx/store';
import { of } from 'rxjs';
import {
GROUP_ID_1,
+ GROUP_ID_2,
GROUP_ID_3,
GROUP_ID_4,
GROUP_ID_5,
@@ -13,6 +14,8 @@ import {
productConfiguration,
productConfigurationWithConflicts,
} from '../../testing/configurator-test-data';
+import { ConfiguratorTestUtils } from '../../testing/configurator-test-utils';
+import { Configurator } from '../model/configurator.model';
import { ConfiguratorActions } from '../state/actions/index';
import { StateWithConfigurator } from '../state/configurator-state';
import { ConfiguratorGroupStatusService } from './configurator-group-status.service';
@@ -96,19 +99,428 @@ describe('ConfiguratorGroupStatusService', () => {
expect(store.dispatch).toHaveBeenCalledWith(expectedAction);
});
+ });
+
+ describe('getFirstIncompleteGroup', () => {
+ const PARENT_TAB_ID = 'parent-tab';
+ const ROW_GROUP_ID = 'CONTAINER_ROW@1067@row-1';
+ const NESTED_TAB_ID = `${ROW_GROUP_ID}@1`;
+ const NESTED_TAB_2_ID = `${ROW_GROUP_ID}@2`;
+ const LATER_TAB_ID = 'later-tab';
+
+ function createAttributeGroup(
+ id: string,
+ options: {
+ complete?: boolean;
+ messages?: Configurator.Message[];
+ subGroups?: Configurator.Group[];
+ } = {}
+ ): Configurator.Group {
+ return {
+ ...ConfiguratorTestUtils.createGroup(id),
+ groupType: Configurator.GroupType.ATTRIBUTE_GROUP,
+ complete: options.complete,
+ messages: options.messages,
+ subGroups: options.subGroups ?? [],
+ };
+ }
+
+ function createRowGroup(
+ id: string,
+ subGroups: Configurator.Group[],
+ options: {
+ complete?: boolean;
+ messages?: Configurator.Message[];
+ } = {}
+ ): Configurator.Group {
+ return {
+ ...ConfiguratorTestUtils.createGroup(id),
+ groupType: Configurator.GroupType.CONTAINER_ROW_GROUP,
+ complete: options.complete,
+ messages: options.messages,
+ subGroups,
+ };
+ }
+
+ function createConfig(
+ groups: Configurator.Group[],
+ flatGroups: Configurator.Group[],
+ options: { messages?: Configurator.Message[] } = {}
+ ): Configurator.Configuration {
+ return {
+ ...ConfiguratorTestUtils.createConfiguration('1'),
+ groups,
+ flatGroups,
+ messages: options.messages,
+ };
+ }
it('should get first incomplete group', () => {
- expect(classUnderTest.getFirstIncompleteGroup(productConfiguration)).toBe(
- productConfiguration.flatGroups[0]
- );
+ expect(
+ classUnderTest.getFirstIncompleteGroup(productConfiguration)?.id
+ ).toBe(productConfiguration.flatGroups[0].id);
});
it('should get first incomplete group - only consider non conflict groups', () => {
expect(
classUnderTest.getFirstIncompleteGroup(
productConfigurationWithConflicts
- )
- ).toBe(productConfigurationWithConflicts.flatGroups[3]);
+ )?.id
+ ).toBe(productConfigurationWithConflicts.flatGroups[3].id);
+ });
+
+ it('should return a complete navigable group that carries a warning message', () => {
+ const warningGroup = createAttributeGroup(GROUP_ID_1, {
+ complete: true,
+ messages: [
+ {
+ message: 'Too many units',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ });
+ const configuration = createConfig([warningGroup], [warningGroup]);
+
+ expect(classUnderTest.getFirstIncompleteGroup(configuration)?.id).toBe(
+ GROUP_ID_1
+ );
+ });
+
+ it('should return a complete navigable group that hosts a container with a warning message', () => {
+ const containerGroup = createAttributeGroup(GROUP_ID_1, {
+ complete: true,
+ });
+ containerGroup.attributes = [
+ {
+ name: 'CONTAINER_ATTR',
+ uiType: Configurator.UiType.CONTAINER,
+ container: {
+ rows: [],
+ messages: [
+ {
+ message: 'Container requires attention',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ },
+ },
+ ];
+ const configuration = createConfig([containerGroup], [containerGroup]);
+
+ expect(classUnderTest.getFirstIncompleteGroup(configuration)?.id).toBe(
+ GROUP_ID_1
+ );
+ });
+
+ it('should not treat a complete group with only a container info message as incomplete', () => {
+ const containerGroup = createAttributeGroup(GROUP_ID_1, {
+ complete: true,
+ });
+ containerGroup.attributes = [
+ {
+ name: 'CONTAINER_ATTR',
+ uiType: Configurator.UiType.CONTAINER,
+ container: {
+ rows: [],
+ messages: [
+ {
+ message: 'Check quantity',
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ ],
+ },
+ },
+ ];
+ const configuration = createConfig([containerGroup], [containerGroup]);
+
+ expect(
+ classUnderTest.getFirstIncompleteGroup(configuration)
+ ).toBeUndefined();
+ });
+
+ it('should not treat a complete group with only an info message as incomplete', () => {
+ const infoGroup = createAttributeGroup(GROUP_ID_1, {
+ complete: true,
+ messages: [
+ {
+ message: 'Check quantity',
+ severity: Configurator.MessageSeverity.INFO,
+ },
+ ],
+ });
+ const configuration = createConfig([infoGroup], [infoGroup]);
+
+ expect(
+ classUnderTest.getFirstIncompleteGroup(configuration)
+ ).toBeUndefined();
+ });
+
+ it('should not treat a complete group with a message without severity as incomplete', () => {
+ const unspecifiedGroup = createAttributeGroup(GROUP_ID_1, {
+ complete: true,
+ messages: [{ message: 'Unspecified tip' }],
+ });
+ const configuration = createConfig(
+ [unspecifiedGroup],
+ [unspecifiedGroup]
+ );
+
+ expect(
+ classUnderTest.getFirstIncompleteGroup(configuration)
+ ).toBeUndefined();
+ });
+
+ it('should resolve a container row group with a warning message to its first nested tab', () => {
+ const nestedTab = createAttributeGroup(NESTED_TAB_ID, {
+ complete: true,
+ });
+ const rowGroup = createRowGroup(ROW_GROUP_ID, [nestedTab], {
+ complete: true,
+ messages: [
+ {
+ message: 'Check zoom range',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ });
+ const parentTab = createAttributeGroup(PARENT_TAB_ID, {
+ complete: true,
+ subGroups: [rowGroup],
+ });
+ const configuration = createConfig([parentTab], [parentTab, nestedTab]);
+
+ expect(classUnderTest.getFirstIncompleteGroup(configuration)?.id).toBe(
+ NESTED_TAB_ID
+ );
+ });
+
+ it('should prefer an incomplete nested tab over the first nested tab of a warning row group', () => {
+ const completeNestedTab = createAttributeGroup(NESTED_TAB_ID, {
+ complete: true,
+ });
+ const incompleteNestedTab = createAttributeGroup(NESTED_TAB_2_ID, {
+ complete: false,
+ });
+ const rowGroup = createRowGroup(
+ ROW_GROUP_ID,
+ [completeNestedTab, incompleteNestedTab],
+ {
+ complete: true,
+ messages: [
+ {
+ message: 'Check zoom range',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ }
+ );
+ const parentTab = createAttributeGroup(PARENT_TAB_ID, {
+ complete: true,
+ subGroups: [rowGroup],
+ });
+ const configuration = createConfig(
+ [parentTab],
+ [parentTab, completeNestedTab, incompleteNestedTab]
+ );
+
+ expect(classUnderTest.getFirstIncompleteGroup(configuration)?.id).toBe(
+ NESTED_TAB_2_ID
+ );
+ });
+
+ it('should resolve a container row group flagged incomplete to its nested configuration', () => {
+ const nestedTab = createAttributeGroup(NESTED_TAB_ID, {
+ complete: true,
+ });
+ const rowGroup = createRowGroup(ROW_GROUP_ID, [nestedTab], {
+ complete: false,
+ });
+ const parentTab = createAttributeGroup(PARENT_TAB_ID, {
+ complete: true,
+ subGroups: [rowGroup],
+ });
+ const configuration = createConfig([parentTab], [parentTab, nestedTab]);
+
+ expect(classUnderTest.getFirstIncompleteGroup(configuration)?.id).toBe(
+ NESTED_TAB_ID
+ );
+ });
+
+ it('should never return a non-navigable group itself', () => {
+ const nestedTab = createAttributeGroup(NESTED_TAB_ID, {
+ complete: true,
+ });
+ const rowGroup = createRowGroup(ROW_GROUP_ID, [nestedTab], {
+ complete: true,
+ messages: [
+ {
+ message: 'Check zoom range',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ });
+ const parentTab = createAttributeGroup(PARENT_TAB_ID, {
+ complete: true,
+ subGroups: [rowGroup],
+ });
+ const configuration = createConfig([parentTab], [parentTab, nestedTab]);
+
+ const result = classUnderTest.getFirstIncompleteGroup(configuration);
+ expect(result?.id).not.toBe(ROW_GROUP_ID);
+ expect(result?.groupType).not.toBe(
+ Configurator.GroupType.CONTAINER_ROW_GROUP
+ );
+ });
+
+ it('should skip a non-navigable group without navigable descendants and continue', () => {
+ const orphanRowGroup = createRowGroup(ROW_GROUP_ID, [], {
+ complete: true,
+ messages: [
+ {
+ message: 'Check zoom range',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ });
+ const laterTab = createAttributeGroup(LATER_TAB_ID, {
+ complete: false,
+ });
+ const configuration = createConfig(
+ [orphanRowGroup, laterTab],
+ [laterTab]
+ );
+
+ expect(classUnderTest.getFirstIncompleteGroup(configuration)?.id).toBe(
+ LATER_TAB_ID
+ );
+ });
+
+ it('should skip conflict groups and conflict header groups', () => {
+ const conflictHeader: Configurator.Group = {
+ ...ConfiguratorTestUtils.createGroup('CONFLICT_HEADER'),
+ groupType: Configurator.GroupType.CONFLICT_HEADER_GROUP,
+ complete: false,
+ subGroups: [
+ {
+ ...ConfiguratorTestUtils.createGroup('CONFLICT_1'),
+ groupType: Configurator.GroupType.CONFLICT_GROUP,
+ complete: false,
+ },
+ ],
+ };
+ const attributeGroup = createAttributeGroup(GROUP_ID_1, {
+ complete: false,
+ });
+ const configuration = createConfig(
+ [conflictHeader, attributeGroup],
+ [conflictHeader.subGroups[0], attributeGroup]
+ );
+
+ expect(classUnderTest.getFirstIncompleteGroup(configuration)?.id).toBe(
+ GROUP_ID_1
+ );
+ });
+
+ it('should return the first root-level navigable group when the root carries typed messages', () => {
+ const firstTab = createAttributeGroup(GROUP_ID_1, { complete: true });
+ const secondTab = createAttributeGroup(GROUP_ID_2, { complete: true });
+ const configuration = createConfig(
+ [firstTab, secondTab],
+ [firstTab, secondTab],
+ {
+ messages: [
+ {
+ message: 'Clean-Up services are needed in addition',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ }
+ );
+
+ expect(classUnderTest.getFirstIncompleteGroup(configuration)?.id).toBe(
+ GROUP_ID_1
+ );
+ });
+
+ it('should prefer the root over a nested incomplete group when the root carries typed messages', () => {
+ const incompleteNestedTab = createAttributeGroup(NESTED_TAB_2_ID, {
+ complete: false,
+ });
+ const rowGroup = createRowGroup(
+ ROW_GROUP_ID,
+ [
+ createAttributeGroup(NESTED_TAB_ID, { complete: true }),
+ incompleteNestedTab,
+ ],
+ { complete: true }
+ );
+ const parentTab = createAttributeGroup(PARENT_TAB_ID, {
+ complete: true,
+ subGroups: [rowGroup],
+ });
+ const configuration = createConfig(
+ [parentTab],
+ [parentTab, incompleteNestedTab],
+ {
+ messages: [
+ {
+ message: 'Clean-Up services are needed in addition',
+ severity: Configurator.MessageSeverity.ERROR,
+ },
+ ],
+ }
+ );
+
+ expect(classUnderTest.getFirstIncompleteGroup(configuration)?.id).toBe(
+ PARENT_TAB_ID
+ );
+ });
+
+ it('should not treat empty root typed messages as incomplete', () => {
+ const completeGroup = createAttributeGroup(GROUP_ID_1, {
+ complete: true,
+ });
+ const configuration = createConfig([completeGroup], [completeGroup], {
+ messages: [{ message: '' }],
+ });
+
+ expect(
+ classUnderTest.getFirstIncompleteGroup(configuration)
+ ).toBeUndefined();
+ });
+
+ it('should skip a conflict group at root level when the root carries typed messages', () => {
+ const conflictHeader: Configurator.Group = {
+ ...ConfiguratorTestUtils.createGroup('CONFLICT_HEADER'),
+ groupType: Configurator.GroupType.CONFLICT_HEADER_GROUP,
+ complete: false,
+ subGroups: [
+ {
+ ...ConfiguratorTestUtils.createGroup('CONFLICT_1'),
+ groupType: Configurator.GroupType.CONFLICT_GROUP,
+ complete: false,
+ },
+ ],
+ };
+ const attributeGroup = createAttributeGroup(GROUP_ID_1, {
+ complete: true,
+ });
+ const configuration = createConfig(
+ [conflictHeader, attributeGroup],
+ [conflictHeader.subGroups[0], attributeGroup],
+ {
+ messages: [
+ {
+ message: 'Clean-Up services are needed in addition',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ }
+ );
+
+ expect(classUnderTest.getFirstIncompleteGroup(configuration)?.id).toBe(
+ GROUP_ID_1
+ );
});
});
});
diff --git a/feature-libs/product-configurator/rulebased/core/facade/configurator-group-status.service.ts b/feature-libs/product-configurator/rulebased/core/facade/configurator-group-status.service.ts
index ec85ee3eb01..e9627f29545 100644
--- a/feature-libs/product-configurator/rulebased/core/facade/configurator-group-status.service.ts
+++ b/feature-libs/product-configurator/rulebased/core/facade/configurator-group-status.service.ts
@@ -42,8 +42,18 @@ export class ConfiguratorGroupStatusService {
}
/**
- * Returns the first non-conflict group of the configuration which is not completed
- * and undefined if all are completed.
+ * Returns the first non-conflict group of the configuration which is not
+ * completed. When the root configuration carries typed messages in
+ * `configuration.messages`, the root is considered incomplete first and the
+ * first navigable top-level group is returned. Otherwise a group is
+ * considered incomplete when its `complete` flag is falsy, when it carries
+ * at least one message with warning severity, or when one of its container
+ * attributes carries a warning message.
+ *
+ * Groups that are not navigation targets (not present in `flatGroups`, e.g.
+ * a container row group) are resolved to a navigable descendant, for example
+ * the first tab of a nested container row configuration. Returns `undefined`
+ * if no such group exists.
*
* @param {Configurator.Configuration} configuration - Configuration
*
@@ -52,13 +62,207 @@ export class ConfiguratorGroupStatusService {
getFirstIncompleteGroup(
configuration: Configurator.Configuration
): Configurator.Group | undefined {
- return configuration.flatGroups
- ? configuration.flatGroups
- .filter(
- (group) => group.groupType !== Configurator.GroupType.CONFLICT_GROUP
- )
- .find((group) => !group.complete)
- : undefined;
+ const navigableGroupIds = new Set(
+ configuration.flatGroups?.map((group) => group.id) ?? []
+ );
+ if (this.hasRootMessages(configuration)) {
+ return this.getFirstRootLevelNavigableGroup(
+ configuration.groups ?? [],
+ navigableGroupIds
+ );
+ }
+ return this.findFirstIncompleteGroup(
+ configuration.groups ?? [],
+ navigableGroupIds
+ );
+ }
+
+ /**
+ * Whether the root configuration carries at least one typed message with
+ * non-empty text.
+ *
+ * @param configuration - Configuration
+ * @returns `true` when root typed messages are present
+ */
+ protected hasRootMessages(
+ configuration: Configurator.Configuration
+ ): boolean {
+ return (
+ configuration.messages?.some((message) => !!message.message) ?? false
+ );
+ }
+
+ /**
+ * Returns the first navigable group at the root level of the configuration.
+ * Only direct entries in `configuration.groups` are considered; nested
+ * subGroups are not searched unless a non-navigable top-level group must be
+ * resolved to a navigable descendant.
+ *
+ * @param groups - Top-level groups of the configuration
+ * @param navigableGroupIds - IDs of groups that are valid navigation targets
+ * @returns First root-level navigable group, or undefined
+ */
+ protected getFirstRootLevelNavigableGroup(
+ groups: Configurator.Group[],
+ navigableGroupIds: Set
+ ): Configurator.Group | undefined {
+ for (const group of groups) {
+ if (this.isConflictRelatedGroup(group)) {
+ continue;
+ }
+ if (navigableGroupIds.has(group.id)) {
+ return group;
+ }
+ const target = this.getNavigationTargetForGroup(group, navigableGroupIds);
+ if (target) {
+ return target;
+ }
+ }
+ return undefined;
+ }
+
+ /**
+ * Depth-first search for the first incomplete non-conflict group.
+ *
+ * @param groups - Groups to search
+ * @param navigableGroupIds - IDs of groups that are valid navigation targets
+ * @returns First incomplete navigable group, or undefined
+ */
+ protected findFirstIncompleteGroup(
+ groups: Configurator.Group[],
+ navigableGroupIds: Set
+ ): Configurator.Group | undefined {
+ for (const group of groups) {
+ if (this.isConflictRelatedGroup(group)) {
+ continue;
+ }
+ if (this.isIncompleteGroup(group)) {
+ const target = navigableGroupIds.has(group.id)
+ ? group
+ : this.getNavigationTargetForGroup(group, navigableGroupIds);
+ if (target) {
+ return target;
+ }
+ }
+ const nestedGroup = this.findFirstIncompleteGroup(
+ group.subGroups ?? [],
+ navigableGroupIds
+ );
+ if (nestedGroup) {
+ return nestedGroup;
+ }
+ }
+ return undefined;
+ }
+
+ /**
+ * Whether the group is incomplete due to its `complete` flag, due to
+ * warning messages, or due to container-level warning messages on one of
+ * its attributes.
+ *
+ * @param group - Group to check
+ * @returns `true` if the group should be treated as incomplete
+ */
+ protected isIncompleteGroup(group: Configurator.Group): boolean {
+ return (
+ !group.complete ||
+ this.hasWarningMessages(group) ||
+ this.hasContainerWarningMessages(group)
+ );
+ }
+
+ /**
+ * Whether the group carries at least one message with warning severity.
+ *
+ * @param group - Group to check
+ * @returns `true` if a warning message is present
+ */
+ protected hasWarningMessages(group: Configurator.Group): boolean {
+ return (
+ group.messages?.some(
+ (message) => message.severity === Configurator.MessageSeverity.WARNING
+ ) ?? false
+ );
+ }
+
+ /**
+ * Whether the group hosts a container attribute with at least one warning
+ * message at container level.
+ *
+ * @param group - Group to check
+ * @returns `true` if a container warning message is present
+ */
+ protected hasContainerWarningMessages(group: Configurator.Group): boolean {
+ return (
+ group.attributes?.some((attribute) =>
+ attribute.container?.messages?.some(
+ (message) => message.severity === Configurator.MessageSeverity.WARNING
+ )
+ ) ?? false
+ );
+ }
+
+ /**
+ * Whether the group belongs to the conflict solver area and must therefore
+ * be skipped by the incomplete-group search.
+ *
+ * @param group - Group to check
+ * @returns `true` for conflict groups and conflict header groups
+ */
+ protected isConflictRelatedGroup(group: Configurator.Group): boolean {
+ return (
+ group.groupType === Configurator.GroupType.CONFLICT_GROUP ||
+ group.groupType === Configurator.GroupType.CONFLICT_HEADER_GROUP
+ );
+ }
+
+ /**
+ * Resolves a non-navigable incomplete group to a navigable descendant.
+ * Prefers a descendant that is itself incomplete; otherwise takes the first
+ * navigable descendant.
+ *
+ * @param group - Non-navigable incomplete group
+ * @param navigableGroupIds - IDs of groups that are valid navigation targets
+ * @returns Navigable descendant, or undefined if none exists
+ */
+ protected getNavigationTargetForGroup(
+ group: Configurator.Group,
+ navigableGroupIds: Set
+ ): Configurator.Group | undefined {
+ const incompleteDescendant = this.findFirstIncompleteGroup(
+ group.subGroups ?? [],
+ navigableGroupIds
+ );
+ if (incompleteDescendant) {
+ return incompleteDescendant;
+ }
+ return this.getFirstNavigableDescendant(group, navigableGroupIds);
+ }
+
+ /**
+ * Returns the first navigable descendant of the given group in pre-order.
+ *
+ * @param group - Group whose descendants are searched
+ * @param navigableGroupIds - IDs of groups that are valid navigation targets
+ * @returns First navigable descendant, or undefined
+ */
+ protected getFirstNavigableDescendant(
+ group: Configurator.Group,
+ navigableGroupIds: Set
+ ): Configurator.Group | undefined {
+ for (const subGroup of group.subGroups ?? []) {
+ if (navigableGroupIds.has(subGroup.id)) {
+ return subGroup;
+ }
+ const nested = this.getFirstNavigableDescendant(
+ subGroup,
+ navigableGroupIds
+ );
+ if (nested) {
+ return nested;
+ }
+ }
+ return undefined;
}
/**
diff --git a/feature-libs/product-configurator/rulebased/core/facade/configurator-groups.service.spec.ts b/feature-libs/product-configurator/rulebased/core/facade/configurator-groups.service.spec.ts
index 2f88c0f86da..25d2fc50a67 100644
--- a/feature-libs/product-configurator/rulebased/core/facade/configurator-groups.service.spec.ts
+++ b/feature-libs/product-configurator/rulebased/core/facade/configurator-groups.service.spec.ts
@@ -507,6 +507,86 @@ describe('ConfiguratorGroupsService', () => {
expect(store.dispatch).toHaveBeenCalledTimes(0);
});
+ it('should navigate to the first root-level tab when only root typed messages exist', () => {
+ const firstTabId = 'root-tab-1';
+ const firstTab: Configurator.Group = {
+ ...ConfiguratorTestUtils.createGroup(firstTabId),
+ groupType: Configurator.GroupType.ATTRIBUTE_GROUP,
+ complete: true,
+ };
+ const configuration: Configurator.Configuration = {
+ ...ConfiguratorTestUtils.createConfiguration('1'),
+ groups: [firstTab],
+ flatGroups: [firstTab],
+ messages: [
+ {
+ message: 'Clean-Up services are needed in addition',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ };
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(configuration)
+ );
+
+ classUnderTest.navigateToFirstIncompleteGroup(configuration.owner);
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new ConfiguratorActions.ChangeGroup({
+ configuration: configuration,
+ groupId: firstTabId,
+ parentGroupId: undefined,
+ conflictResolutionMode: false,
+ })
+ );
+ });
+ it('should navigate to the nested tab of a container row group flagged by a warning message', () => {
+ const nestedTabId = 'CONTAINER_ROW@1067@row-1@1';
+ const rowGroupId = 'CONTAINER_ROW@1067@row-1';
+ const parentTabId = 'parent-tab';
+ const nestedTab: Configurator.Group = {
+ ...ConfiguratorTestUtils.createGroup(nestedTabId),
+ groupType: Configurator.GroupType.ATTRIBUTE_GROUP,
+ complete: true,
+ };
+ const rowGroup: Configurator.Group = {
+ ...ConfiguratorTestUtils.createGroup(rowGroupId),
+ groupType: Configurator.GroupType.CONTAINER_ROW_GROUP,
+ complete: true,
+ messages: [
+ {
+ message: 'Check zoom range',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ],
+ subGroups: [nestedTab],
+ };
+ const parentTab: Configurator.Group = {
+ ...ConfiguratorTestUtils.createGroup(parentTabId),
+ groupType: Configurator.GroupType.ATTRIBUTE_GROUP,
+ complete: true,
+ subGroups: [rowGroup],
+ };
+ const configuration: Configurator.Configuration = {
+ ...ConfiguratorTestUtils.createConfiguration('1'),
+ groups: [parentTab],
+ flatGroups: [parentTab, nestedTab],
+ };
+ spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue(
+ of(configuration)
+ );
+
+ classUnderTest.navigateToFirstIncompleteGroup(configuration.owner);
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new ConfiguratorActions.ChangeGroup({
+ configuration: configuration,
+ groupId: nestedTabId,
+ parentGroupId: rowGroupId,
+ conflictResolutionMode: false,
+ })
+ );
+ });
});
it('should delegate calls for parent group to the facade utils service', () => {
diff --git a/feature-libs/product-configurator/rulebased/core/facade/configurator-groups.service.ts b/feature-libs/product-configurator/rulebased/core/facade/configurator-groups.service.ts
index 5a1f33f2d7e..29b97daf55c 100644
--- a/feature-libs/product-configurator/rulebased/core/facade/configurator-groups.service.ts
+++ b/feature-libs/product-configurator/rulebased/core/facade/configurator-groups.service.ts
@@ -64,10 +64,18 @@ export class ConfiguratorGroupsService {
}
/**
- * Navigates to the first non-conflict group of the configuration which is not completed.
- * This method assumes that the configuration has incomplete groups,
- * the caller has to verify this prior to calling this method. In case no incomplete group is
- * present, nothing will happen
+ * Navigates to the first non-conflict group of the configuration which is not
+ * completed. When the root configuration carries typed messages, the root is
+ * considered incomplete first and the first navigable top-level group is
+ * targeted. Otherwise a group is considered incomplete when its `complete`
+ * flag is falsy or when it carries at least one message with warning
+ * severity. Groups that are not navigation targets (not present in
+ * `flatGroups`) are resolved to a navigable descendant, for example the first
+ * tab of a nested container row configuration.
+ *
+ * This method assumes that the configuration has incomplete groups; the
+ * caller has to verify this prior to calling this method. In case no
+ * incomplete group is present, nothing will happen.
*
* @param {CommonConfigurator.Owner} owner - Configuration owner
*/
diff --git a/feature-libs/product-configurator/rulebased/core/facade/utils/configurator-utils.service.ts b/feature-libs/product-configurator/rulebased/core/facade/utils/configurator-utils.service.ts
index fd97503edc6..ed5a00eb191 100644
--- a/feature-libs/product-configurator/rulebased/core/facade/utils/configurator-utils.service.ts
+++ b/feature-libs/product-configurator/rulebased/core/facade/utils/configurator-utils.service.ts
@@ -57,7 +57,7 @@ export class ConfiguratorUtilsService {
return currentGroup;
}
const groupFound = this.getGroupFromSubGroups(groups, groupId);
- return groupFound ? groupFound : groups[0];
+ return groupFound ?? groups[0];
}
/**
@@ -72,9 +72,7 @@ export class ConfiguratorUtilsService {
groupId: string
): Configurator.Group | undefined {
const currentGroup = groups.find((group) => group.id === groupId);
- return currentGroup
- ? currentGroup
- : this.getGroupFromSubGroups(groups, groupId);
+ return currentGroup ?? this.getGroupFromSubGroups(groups, groupId);
}
protected getGroupByIdIfPresent(
@@ -93,7 +91,7 @@ export class ConfiguratorUtilsService {
groups: Configurator.Group[],
groupId: string
): Configurator.Group | undefined {
- const groupFound = groups
+ return groups
.map((group) => {
return group.subGroups
? this.getGroupByIdIfPresent(group.subGroups, groupId)
@@ -101,7 +99,6 @@ export class ConfiguratorUtilsService {
})
.filter((foundGroup) => foundGroup)
.pop();
- return groupFound;
}
/**
@@ -259,11 +256,10 @@ export class ConfiguratorUtilsService {
protected buildGroupForExtract(
group: Configurator.Group
): Configurator.Group {
- const changedGroup: Configurator.Group = {
+ return {
groupType: group.groupType,
id: group.id,
subGroups: [],
};
- return changedGroup;
}
}
diff --git a/feature-libs/product-configurator/rulebased/core/model/configurator.model.ts b/feature-libs/product-configurator/rulebased/core/model/configurator.model.ts
index 6f12efcdb68..ed9c292609c 100644
--- a/feature-libs/product-configurator/rulebased/core/model/configurator.model.ts
+++ b/feature-libs/product-configurator/rulebased/core/model/configurator.model.ts
@@ -60,7 +60,7 @@ export namespace Configurator {
export interface Container {
minRows?: number;
maxRows?: number;
- failedValidations?: string[];
+ messages?: Message[];
rows: ContainerRow[];
}
@@ -69,6 +69,8 @@ export namespace Configurator {
*/
export interface ContainerRow {
id: string;
+ minRows?: number;
+ maxRows?: number;
productSystemId?: string;
productName?: string;
selected?: boolean;
@@ -144,6 +146,17 @@ export namespace Configurator {
updateType?: UpdateType;
errorMessages?: string[];
warningMessages?: string[];
+ /**
+ * Typed messages from the configuration engine, including severity.
+ * Used for CPQ when `hasFullConfigurationState` is true.
+ */
+ messages?: Message[];
+ /**
+ * Whether the CPQ payload contains the full configuration state
+ * (all tabs, typed messages). When true, root messages are taken from
+ * `messages` rather than from `errorMessages`/`warningMessages`.
+ */
+ hasFullConfigurationState?: boolean;
variants?: Variant[];
kbKey?: KB;
pricingEnabled?: boolean;
@@ -294,6 +307,7 @@ export namespace Configurator {
export enum MessageSeverity {
INFO = 'info',
WARNING = 'warning',
+ ERROR = 'error',
}
export enum UiType {
diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer-utils.service.spec.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer-utils.service.spec.ts
index fbdeacefdba..c0f270f9f84 100644
--- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer-utils.service.spec.ts
+++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer-utils.service.spec.ts
@@ -508,4 +508,225 @@ describe('CpqConfiguratorNormalizerUtilsService', () => {
cpqConfiguratorNormalizerUtilsService.convertAttributeLabel(attribute)
).toBe('');
});
+
+ describe('issue counting', () => {
+ const ERROR_MSG = 'This is an error message';
+ const VALIDATION_MSG = 'this is a failed validation';
+ const INVALID_MSG = 'This is an invalid message';
+ const INCOMPLETE_ATTR_1 = 'Attribute1';
+ const INCOMPLETE_ATTR_2 = 'Attribute2';
+ const INCOMPLETE_MSG = 'incomplete message';
+
+ const rootConfiguration: Cpq.Configuration = {
+ productSystemId: 'productSystemId',
+ currencyISOCode: CURRENCY,
+ incompleteMessages: [INCOMPLETE_MSG],
+ incompleteAttributes: [INCOMPLETE_ATTR_1, INCOMPLETE_ATTR_2],
+ invalidMessages: [INVALID_MSG],
+ failedValidations: [VALIDATION_MSG],
+ errorMessages: [ERROR_MSG],
+ messages: [
+ { message: 'Typed warning', severity: Cpq.MessageSeverity.WARNING },
+ { message: 'Typed info', severity: Cpq.MessageSeverity.INFO },
+ { message: '' },
+ ],
+ };
+
+ const nestedConfiguration: Cpq.NestedProductConfiguration = {
+ completed: false,
+ errorMessages: [ERROR_MSG],
+ invalidMessages: [INVALID_MSG],
+ failedValidations: [VALIDATION_MSG],
+ incompleteMessages: [INCOMPLETE_MSG],
+ messages: [
+ { message: 'Check zoom range', severity: Cpq.MessageSeverity.WARNING },
+ { message: 'Info only', severity: Cpq.MessageSeverity.INFO },
+ ],
+ };
+
+ it('should count root-level issues including typed messages', () => {
+ expect(
+ cpqConfiguratorNormalizerUtilsService['countIssues'](rootConfiguration)
+ ).toBe(8);
+ });
+
+ it('should count nested configuration issues from tab attributes marked incomplete', () => {
+ expect(
+ cpqConfiguratorNormalizerUtilsService['countIssues'](
+ nestedConfiguration
+ )
+ ).toBe(6);
+ expect(
+ cpqConfiguratorNormalizerUtilsService['countIssues']({
+ ...nestedConfiguration,
+ tabs: [
+ {
+ id: 1,
+ attributes: [
+ { pA_ID: 1, stdAttrCode: 11, incomplete: true },
+ { pA_ID: 2, stdAttrCode: 12, incomplete: true },
+ { pA_ID: 3, stdAttrCode: 13, incomplete: false },
+ ],
+ },
+ {
+ id: 2,
+ attributes: [{ pA_ID: 4, stdAttrCode: 14, incomplete: true }],
+ },
+ ],
+ })
+ ).toBe(9);
+ });
+
+ it('should count issues in nested container configurations', () => {
+ const containers: Cpq.Container[] = [
+ {
+ stdAttrCode: 1,
+ rows: [
+ {
+ id: '1',
+ configuration: nestedConfiguration,
+ },
+ ],
+ },
+ ];
+ expect(
+ cpqConfiguratorNormalizerUtilsService['countIssuesInContainers'](
+ containers
+ )
+ ).toBe(6);
+ });
+
+ it('should count container-level warning messages', () => {
+ const containers: Cpq.Container[] = [
+ {
+ stdAttrCode: 1,
+ messages: [
+ {
+ message: 'Container warning',
+ severity: Cpq.MessageSeverity.WARNING,
+ },
+ { message: 'Info only', severity: Cpq.MessageSeverity.INFO },
+ { message: '', severity: Cpq.MessageSeverity.WARNING },
+ ],
+ rows: [
+ {
+ id: '1',
+ configuration: nestedConfiguration,
+ },
+ ],
+ },
+ ];
+ expect(
+ cpqConfiguratorNormalizerUtilsService['countIssuesInContainers'](
+ containers
+ )
+ ).toBe(7);
+ });
+
+ it('should count root typed messages and incomplete attributes when messages are present', () => {
+ expect(
+ cpqConfiguratorNormalizerUtilsService.calculateTotalNumberOfIssues(
+ rootConfiguration
+ )
+ ).toBe(4);
+ });
+
+ it('should count only typed messages when incomplete attributes are absent', () => {
+ expect(
+ cpqConfiguratorNormalizerUtilsService.calculateTotalNumberOfIssues({
+ ...rootConfiguration,
+ incompleteAttributes: undefined,
+ })
+ ).toBe(2);
+ });
+
+ it('should ignore legacy root message arrays when typed messages are present', () => {
+ expect(
+ cpqConfiguratorNormalizerUtilsService.calculateTotalNumberOfIssues({
+ ...rootConfiguration,
+ errorMessages: [ERROR_MSG, ERROR_MSG],
+ invalidMessages: [INVALID_MSG, INVALID_MSG],
+ failedValidations: [VALIDATION_MSG],
+ incompleteMessages: [INCOMPLETE_MSG],
+ })
+ ).toBe(4);
+ });
+
+ it('should count all root message containers when messages are absent', () => {
+ const rootWithoutMessages: Cpq.Configuration = {
+ ...rootConfiguration,
+ messages: undefined,
+ };
+ expect(
+ cpqConfiguratorNormalizerUtilsService.calculateTotalNumberOfIssues(
+ rootWithoutMessages
+ )
+ ).toBe(6);
+ });
+
+ it('should still count all nested message containers when nested messages are present', () => {
+ expect(
+ cpqConfiguratorNormalizerUtilsService.calculateTotalNumberOfIssues({
+ productSystemId: 'productSystemId',
+ currencyISOCode: CURRENCY,
+ sapContainers: [
+ {
+ stdAttrCode: 1,
+ rows: [
+ {
+ id: '1',
+ configuration: nestedConfiguration,
+ },
+ ],
+ },
+ ],
+ })
+ ).toBe(6);
+ });
+
+ it('should count issues recursively in nested containers', () => {
+ const containers: Cpq.Container[] = [
+ {
+ stdAttrCode: 1,
+ rows: [
+ {
+ id: '1',
+ configuration: {
+ ...nestedConfiguration,
+ containers: [
+ {
+ stdAttrCode: 2,
+ rows: [
+ {
+ id: '2',
+ configuration: {
+ completed: false,
+ errorMessages: [ERROR_MSG],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ },
+ ];
+ expect(
+ cpqConfiguratorNormalizerUtilsService.calculateTotalNumberOfIssues({
+ ...rootConfiguration,
+ sapContainers: containers,
+ })
+ ).toBe(11);
+ });
+
+ it('should return zero when no issues exist', () => {
+ expect(
+ cpqConfiguratorNormalizerUtilsService.calculateTotalNumberOfIssues({
+ productSystemId: 'productSystemId',
+ currencyISOCode: CURRENCY,
+ })
+ ).toBe(0);
+ });
+ });
});
diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer-utils.service.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer-utils.service.ts
index 3f2ebe3094b..72a0a13b5b6 100644
--- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer-utils.service.ts
+++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-normalizer-utils.service.ts
@@ -262,6 +262,145 @@ export class CpqConfiguratorNormalizerUtilsService {
: '';
}
+ /**
+ * Calculates the total number of issues for a CPQ configuration,
+ * including issues in nested container-row configurations.
+ *
+ * When the root configuration contains at least one typed message,
+ * those `messages` plus `incompleteAttributes` are counted at root
+ * level. Other root-level message containers are ignored. Nested
+ * configurations are counted unchanged.
+ *
+ * @param source - CPQ configuration
+ * @returns Total number of issues
+ */
+ calculateTotalNumberOfIssues(source: Cpq.Configuration): number {
+ const rootTypedMessages = this.countTypedMessages(source);
+ const incompleteAttributes = source.incompleteAttributes?.length ?? 0;
+ const rootContribution =
+ rootTypedMessages > 0
+ ? rootTypedMessages + incompleteAttributes
+ : this.countIssues(source);
+ const nestedContribution = this.countIssuesInContainers(
+ source.sapContainers,
+ 'root.sapContainers'
+ );
+ return rootContribution + nestedContribution;
+ }
+
+ /**
+ * Counts issues on a CPQ configuration or nested product configuration.
+ *
+ * @param source - CPQ configuration or nested product configuration
+ * @param context - Optional context label for temporary logging
+ * @returns Number of issues at this level
+ */
+ protected countIssues(
+ source: Cpq.Configuration | Cpq.NestedProductConfiguration
+ ): number {
+ const incompleteAttributes =
+ 'incompleteAttributes' in source
+ ? (source.incompleteAttributes?.length ?? 0)
+ : this.countIncompleteAttributesInTabs(source);
+ const incompleteMessages = source.incompleteMessages?.length ?? 0;
+ const invalidMessages = source.invalidMessages?.length ?? 0;
+ const failedValidations = source.failedValidations?.length ?? 0;
+ const errorMessages = source.errorMessages?.length ?? 0;
+ const typedMessages = this.countTypedMessages(source);
+ return (
+ incompleteAttributes +
+ incompleteMessages +
+ invalidMessages +
+ failedValidations +
+ errorMessages +
+ typedMessages
+ );
+ }
+
+ /**
+ * Counts typed messages with non-empty message text.
+ *
+ * @param source - CPQ configuration or nested product configuration
+ * @returns Number of typed messages
+ */
+ protected countTypedMessages(
+ source: Cpq.Configuration | Cpq.NestedProductConfiguration
+ ): number {
+ return source.messages?.filter((message) => !!message.message).length ?? 0;
+ }
+
+ /**
+ * Counts attributes marked as incomplete within all tabs of a configuration.
+ * Used for nested product configurations where incomplete attributes are
+ * indicated per attribute rather than via the root-level incompleteAttributes
+ * array.
+ *
+ * @param source - CPQ configuration or nested product configuration
+ * @returns Number of incomplete attributes across all tabs
+ */
+ protected countIncompleteAttributesInTabs(
+ source: Cpq.Configuration | Cpq.NestedProductConfiguration
+ ): number {
+ return (
+ source.tabs?.reduce(
+ (count, tab) =>
+ count +
+ (tab.attributes?.filter((attribute) => attribute.incomplete === true)
+ .length ?? 0),
+ 0
+ ) ?? 0
+ );
+ }
+
+ /**
+ * Recursively counts issues in nested container configurations.
+ *
+ * @param containers - CPQ containers
+ * @param context - Optional context label for temporary logging
+ * @returns Number of issues in nested configurations
+ */
+ /**
+ * Counts warning messages at container level with non-empty message text.
+ *
+ * @param container - CPQ container
+ * @returns Number of container-level warning messages
+ */
+ protected countContainerWarningMessages(container: Cpq.Container): number {
+ return (
+ container.messages?.filter(
+ (message) =>
+ !!message.message && message.severity === Cpq.MessageSeverity.WARNING
+ ).length ?? 0
+ );
+ }
+
+ protected countIssuesInContainers(
+ containers?: Cpq.Container[],
+ context = 'unknown'
+ ): number {
+ if (!containers?.length) {
+ return 0;
+ }
+ return containers.reduce((containerTotal, container) => {
+ const containerWarningMessages =
+ this.countContainerWarningMessages(container);
+ const rowIssues = (container.rows ?? []).reduce((rowTotal, row) => {
+ if (!row.configuration) {
+ return rowTotal;
+ }
+ const rowContext = `${context}.container[${container.stdAttrCode}].row[${row.id}]`;
+ const configurationIssues = this.countIssues(row.configuration);
+ const nestedContainerIssues = this.countIssuesInContainers(
+ row.configuration.containers,
+ `${rowContext}.containers`
+ );
+ const rowContribution = configurationIssues + nestedContainerIssues;
+ return rowTotal + rowContribution;
+ }, 0);
+ return containerTotal + containerWarningMessages + rowIssues;
+ }, 0);
+ }
+
/**
* Gets the current language.
*
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 0ce3f81cb81..622c94b353b 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
@@ -211,12 +211,12 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should be created', () => {
+ it('should create an injectable normalizer instance', () => {
expect(cpqConfiguratorNormalizer).toBeTruthy();
});
describe('convert', () => {
- it('should convert a configuration into the configurator independent format', () => {
+ it('should map product code, completeness, groups, prices and an empty configId', () => {
const result = cpqConfiguratorNormalizer.convert(cpqConfiguration);
expect(result.productCode).toBe(cpqProductSystemId);
expect(result.complete).toBe(true);
@@ -237,7 +237,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.configId).toBe('');
});
- it('should use tab attributes for CPQ version V2 or higher', () => {
+ it('should assign each tab its own attributes when hasFullConfigurationState is true', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfiguration,
hasFullConfigurationState: true,
@@ -262,7 +262,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should set configuration id if provided', () => {
+ it('should copy configurationId onto configId', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfiguration,
configurationId: cpqConfigurationId,
@@ -270,7 +270,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.configId).toBe(cpqConfigurationId);
});
- it('should set target to incomplete if incomplete attributes are undefined', () => {
+ it('should treat missing incompleteAttributes as a complete configuration', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfiguration,
incompleteAttributes: undefined,
@@ -278,7 +278,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.complete).toBe(true);
});
- it('should set target to inconsistent if invalid messages are present and others empty', () => {
+ it('should mark the configuration inconsistent when only invalidMessages are present (other issue lists empty)', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfigurationIncompleteInconsistent,
failedValidations: [],
@@ -288,7 +288,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.consistent).toBe(false);
});
- it('should set target to inconsistent if invalid messages are present and others undefined', () => {
+ it('should mark the configuration inconsistent when only invalidMessages are present (other issue lists undefined)', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfigurationIncompleteInconsistent,
failedValidations: undefined,
@@ -298,7 +298,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.consistent).toBe(false);
});
- it('should set target to inconsistent if failed validations are present and others empty', () => {
+ it('should mark the configuration inconsistent when only failedValidations are present (other issue lists empty)', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfigurationIncompleteInconsistent,
invalidMessages: [],
@@ -308,7 +308,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.consistent).toBe(false);
});
- it('should set target to inconsistent if failed validations are present and others undefined', () => {
+ it('should mark the configuration inconsistent when only failedValidations are present (other issue lists undefined)', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfigurationIncompleteInconsistent,
invalidMessages: undefined,
@@ -318,7 +318,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.consistent).toBe(false);
});
- it('should set target to inconsistent if incomplete messages are present and others empty', () => {
+ it('should mark the configuration inconsistent when only incompleteMessages are present (other issue lists empty)', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfigurationIncompleteInconsistent,
invalidMessages: [],
@@ -328,7 +328,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.consistent).toBe(false);
});
- it('should set target to inconsistent if incomplete messages are present and others undefined', () => {
+ it('should mark the configuration inconsistent when only incompleteMessages are present (other issue lists undefined)', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfigurationIncompleteInconsistent,
invalidMessages: undefined,
@@ -338,7 +338,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.consistent).toBe(false);
});
- it('should set target to inconsistent if error messages are present and others empty', () => {
+ it('should mark the configuration inconsistent when only errorMessages are present (other issue lists empty)', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfigurationIncompleteInconsistent,
invalidMessages: [],
@@ -348,7 +348,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.consistent).toBe(false);
});
- it('should set target to inconsistent if error messages are present and others undefined', () => {
+ it('should mark the configuration inconsistent when only errorMessages are present (other issue lists undefined)', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfigurationIncompleteInconsistent,
invalidMessages: undefined,
@@ -358,7 +358,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.consistent).toBe(false);
});
- it('should convert an incomplete inconsistent configuration', () => {
+ it('should mark the configuration incomplete and inconsistent when both issues and incomplete attributes exist', () => {
const result = cpqConfiguratorNormalizer.convert(
cpqConfigurationIncompleteInconsistent
);
@@ -368,7 +368,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.totalNumberOfIssues).toBe(6);
});
- it('should convert a complete inconsistent configuration', () => {
+ it('should mark the configuration complete but inconsistent when only consistency issues exist', () => {
const result = cpqConfiguratorNormalizer.convert(
cpqConfigurationCompleteInconsistent
);
@@ -378,7 +378,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.totalNumberOfIssues).toBe(4);
});
- it('should create one group (generic) if no tabs are present (undefined)', () => {
+ it('should fall back to a single generic group when tabs are undefined', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfigurationCompleteInconsistent,
tabs: undefined,
@@ -386,7 +386,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.groups.length).toBe(1);
});
- it('should handle situation that both attributes and tabs are undefined, and create the generic group without attributes ', () => {
+ it('should create an empty generic group when both tabs and attributes are undefined', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfigurationCompleteInconsistent,
attributes: undefined,
@@ -396,7 +396,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.groups[0].attributes).toEqual([]);
});
- it('should handle situation that attributes are undefined and create groups without attributes', () => {
+ it('should convert tabs to groups with empty attributes when source attributes are undefined', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfigurationCompleteInconsistent,
attributes: undefined,
@@ -406,7 +406,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.groups[1].attributes).toEqual([]);
});
- it('should create a complete generic group if no tabs and no incomplete attributes are defined ', () => {
+ it('should mark the generic group complete when tabs and incompleteAttributes are undefined', () => {
const result = cpqConfiguratorNormalizer.convert({
...cpqConfigurationCompleteInconsistent,
incompleteAttributes: undefined,
@@ -416,7 +416,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.groups[0].complete).toBe(true);
});
- it('should map error message from conflict, errror and invalid messages and incomplete attributes', () => {
+ it('should collect errorMessages from error and invalid messages', () => {
const mappedConfiguration = cpqConfiguratorNormalizer.convert(
cpqConfigurationIncompleteInconsistent
);
@@ -426,7 +426,7 @@ describe('CpqConfiguratorNormalizer', () => {
checkMessagePresent(mappedConfiguration.errorMessages, INVALID_MSG);
});
- it('should map warning message from failed validations', () => {
+ it('should collect warningMessages from failed validations and incomplete messages', () => {
const mappedConfiguration = cpqConfiguratorNormalizer.convert(
cpqConfigurationIncompleteInconsistent
);
@@ -435,7 +435,51 @@ describe('CpqConfiguratorNormalizer', () => {
checkMessagePresent(mappedConfiguration.warningMessages, INCOMPLETE_MSG);
});
- it('should enable pricing', () => {
+ it('should map typed messages with severity and keep hasFullConfigurationState true', () => {
+ const mappedConfiguration = cpqConfiguratorNormalizer.convert({
+ ...cpqConfigurationIncompleteInconsistent,
+ messages: [
+ {
+ message: 'Check zoom range',
+ severity: Cpq.MessageSeverity.WARNING,
+ },
+ {
+ message: 'Info only',
+ severity: Cpq.MessageSeverity.INFO,
+ },
+ ],
+ });
+ expect(mappedConfiguration.hasFullConfigurationState).toBe(true);
+ expect(mappedConfiguration.messages).toEqual([
+ {
+ message: 'Check zoom range',
+ severity: Configurator.MessageSeverity.ERROR,
+ },
+ {
+ message: 'Info only',
+ severity: Configurator.MessageSeverity.WARNING,
+ },
+ ]);
+ expect(mappedConfiguration.errorMessages?.length).toBe(2);
+ expect(mappedConfiguration.warningMessages?.length).toBe(2);
+ });
+
+ it('should leave typed root messages undefined when the source has none', () => {
+ const mappedConfiguration =
+ cpqConfiguratorNormalizer.convert(cpqConfiguration);
+ expect(mappedConfiguration.hasFullConfigurationState).toBe(true);
+ expect(mappedConfiguration.messages).toBeUndefined();
+ });
+
+ it('should preserve hasFullConfigurationState when the source sets it to false', () => {
+ const mappedConfiguration = cpqConfiguratorNormalizer.convert({
+ ...cpqConfiguration,
+ hasFullConfigurationState: false,
+ });
+ expect(mappedConfiguration.hasFullConfigurationState).toBe(false);
+ });
+
+ it('should always enable pricing on the converted configuration', () => {
const mappedConfiguration =
cpqConfiguratorNormalizer.convert(cpqConfiguration);
expect(mappedConfiguration.pricingEnabled).toBe(true);
@@ -443,13 +487,13 @@ describe('CpqConfiguratorNormalizer', () => {
});
describe('convertValueCode', () => {
- it('should return `###RETRACT_VALUE_CODE###` in case value code is zero', () => {
+ it('should map paV_ID 0 to the retract value code', () => {
expect(cpqConfiguratorNormalizer['convertValueCode'](0)).toEqual(
Configurator.RetractValueCode
);
});
- it('should return string of value code in case not zero', () => {
+ it('should stringify a non-zero paV_ID as the valueCode', () => {
const pav_ID = 8462;
expect(cpqConfiguratorNormalizer['convertValueCode'](pav_ID)).toEqual(
pav_ID.toString()
@@ -458,7 +502,7 @@ describe('CpqConfiguratorNormalizer', () => {
});
describe('convertValue', () => {
- it('should convert values', () => {
+ it('should map valueCode, name, display, description, productSystemId, selected and quantity', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -484,7 +528,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(value.quantity).toBe(3);
});
- it('should map prices during value conversion', () => {
+ it('should map valuePrice and valuePriceTotal (quantity × unit price) onto the converted value', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -513,7 +557,7 @@ describe('CpqConfiguratorNormalizer', () => {
});
});
- it('should remove value "No option selected" for required DDLB when a "real" value already selected', () => {
+ it('should skip the retract option for a required dropdown that already has a real selection', () => {
const cpqValueA: Cpq.Value = { paV_ID: 0, selected: false };
const cpqValueB: Cpq.Value = { paV_ID: 1, selected: true };
const cpqAttr: Cpq.Attribute = {
@@ -533,7 +577,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values.length).toBe(0);
});
- it('should not remove value "No option selected" for non required DDLB when a "real" value already selected', () => {
+ it('should keep the retract option for a non-required dropdown that already has a real selection', () => {
const cpqValueA: Cpq.Value = { paV_ID: 0, selected: false };
const cpqValueB: Cpq.Value = { paV_ID: 1, selected: true };
const cpqAttr: Cpq.Attribute = {
@@ -555,8 +599,8 @@ describe('CpqConfiguratorNormalizer', () => {
});
});
- describe('convertValue', () => {
- it('should convert attributes with values - no sysId', () => {
+ describe('convertAttribute', () => {
+ it('should map a radio-button attribute whose values have no product sysId', () => {
const attributeList: Configurator.Attribute[] = [];
const cpqValueNoSysId1: Cpq.Value = { ...cpqValue };
@@ -600,7 +644,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values?.[0].valueCode).toBe(cpqValuePavId.toString());
});
- it('should convert attributes with values - with many sysId', () => {
+ it('should map a radio-button-product attribute when multiple values have a product sysId', () => {
const attributeList: Configurator.Attribute[] = [];
cpqConfiguratorNormalizer['convertAttribute'](
@@ -637,7 +681,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values?.length).toBe(2);
});
- it('should convert attributes with values - with only 1 sysId', () => {
+ it('should still use the product radio-button ui type when only one value has a sysId', () => {
const attributeList: Configurator.Attribute[] = [];
const cpqValueNoSysId: Cpq.Value = { ...cpqValue };
@@ -677,7 +721,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values?.length).toBe(2);
});
- it('should convert attributes without values', () => {
+ it('should map a string input attribute that has no values', () => {
const attributeList: Configurator.Attribute[] = [];
cpqConfiguratorNormalizer['convertAttribute'](
@@ -707,7 +751,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values?.length).toBe(undefined);
});
- it('should use attribute name when attribute label is not available', () => {
+ it('should fall back to the CPQ name when the attribute label is missing', () => {
const attributeList: Configurator.Attribute[] = [];
const cpqAttributeWithoutLabel: Cpq.Attribute = {
...cpqAttribute,
@@ -725,7 +769,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(attribute.label).toBe('AttributeName');
});
- it('should mark all attributes visible', () => {
+ it('should set visible to true on every converted attribute', () => {
const attributeList: Configurator.Attribute[] = [];
cpqConfiguratorNormalizer['convertAttribute'](
@@ -741,7 +785,7 @@ describe('CpqConfiguratorNormalizer', () => {
});
describe('convertGroup', () => {
- it('should convert a group', () => {
+ it('should map tab id, name, completeness and attributes onto an attribute group', () => {
const groups: Configurator.Group[] = [];
const flatGroups: Configurator.Group[] = [];
cpqConfiguratorNormalizer['convertGroup'](
@@ -771,7 +815,7 @@ describe('CpqConfiguratorNormalizer', () => {
}
});
- it('should convert a generic group', () => {
+ it('should build the _GEN group with translated description and source attributes', () => {
const groups: Configurator.Group[] = [];
const flatGroups: Configurator.Group[] = [];
const incompleteAttributes: string[] = ['Attribute1', 'Attribute2'];
@@ -803,8 +847,8 @@ describe('CpqConfiguratorNormalizer', () => {
});
describe('convertAttributeType', () => {
- describe('when dealing with attribute with at least one value containing sysId', () => {
- it('should return UIType RADIOBUTTON_PRODUCT for CPQ DisplayAs RADIO_BUTTON', () => {
+ describe('when at least one value has a productSystemId', () => {
+ it('should map RADIO_BUTTON with a product value to RADIOBUTTON_PRODUCT', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -817,7 +861,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return UIType DROPDOWN_PRODUCT for CPQ DisplayAs DROPDOWN', () => {
+ it('should map DROPDOWN with a product value to DROPDOWN_PRODUCT', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -830,7 +874,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return UIType CHECKBOXLIST_PRODUCT for CPQ DisplayAs CHECK_BOX', () => {
+ it('should map CHECK_BOX with a product value to CHECKBOXLIST_PRODUCT', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -844,8 +888,8 @@ describe('CpqConfiguratorNormalizer', () => {
});
});
- describe('when dealing with attribute with no values containing sysId', () => {
- it('should return UIType RADIOBUTTON for CPQ DisplayAs RADIO_BUTTON', () => {
+ describe('when no value has a productSystemId', () => {
+ it('should map RADIO_BUTTON without a product value to RADIOBUTTON', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -858,7 +902,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return UIType DROPDOWN for CPQ DisplayAs DROPDOWN', () => {
+ it('should map DROPDOWN without a product value to DROPDOWN', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -871,7 +915,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return UIType CHECKBOXLIST for CPQ DisplayAs CHECK_BOX', () => {
+ it('should map CHECK_BOX without a product value to CHECKBOXLIST', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -884,7 +928,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return UIType STRING for CPQ DisplayAs INPUT and DataType INPUT_STRING', () => {
+ it('should map INPUT with INPUT_STRING data type to STRING', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -898,7 +942,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return UIType CONTAINER for CPQ DisplayAs CONTAINER', () => {
+ it('should map CONTAINER displayAs to CONTAINER ui type', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -911,7 +955,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return UIType NOT_IMPLEMENTED for CPQ DisplayAs INPUT and DataType differt from INPUT_STRING', () => {
+ it('should map INPUT with a non-string data type to NOT_IMPLEMENTED', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -925,7 +969,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return UIType NOT_IMPLEMENTED for CPQ DisplayAs READ_ONLY', () => {
+ it('should map CPQ READ_ONLY displayAs to NOT_IMPLEMENTED', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -938,7 +982,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return UIType NOT_IMPLEMENTED for unknown (not supported) CPQ DisplayAs', () => {
+ it('should map an unsupported displayAs such as LIST_BOX to NOT_IMPLEMENTED', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -951,7 +995,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return UIType READ_ONLY for supported CPQ DisplayAs when attribute is not enabled', () => {
+ it('should map a supported displayAs to READ_ONLY when isEnabled is false', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -963,7 +1007,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return UIType READ_ONLY for supported CPQ DisplayAs when attribute enabled facet is not defined', () => {
+ it('should map a supported displayAs to READ_ONLY when isEnabled is undefined', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -975,7 +1019,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return UIType NOT_IMPLEMENTED for not supported CPQ DisplayAs when attribute is not enabled', () => {
+ it('should keep LIST_BOX as NOT_IMPLEMENTED even when isEnabled is false', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -990,37 +1034,39 @@ describe('CpqConfiguratorNormalizer', () => {
});
});
- it('should set selectedSingleValue', () => {
- const configAttribute: Configurator.Attribute = {
- name: 'ATTRIBUTE_NAME',
- values: [{ valueCode: 'VK1' }, { valueCode: 'VK2', selected: true }],
- };
- cpqConfiguratorNormalizer['setSelectedSingleValue'](configAttribute);
- expect(configAttribute.selectedSingleValue).toBe('VK2');
- });
+ describe('setSelectedSingleValue', () => {
+ it('should copy the valueCode when exactly one value is selected', () => {
+ const configAttribute: Configurator.Attribute = {
+ name: 'ATTRIBUTE_NAME',
+ values: [{ valueCode: 'VK1' }, { valueCode: 'VK2', selected: true }],
+ };
+ cpqConfiguratorNormalizer['setSelectedSingleValue'](configAttribute);
+ expect(configAttribute.selectedSingleValue).toBe('VK2');
+ });
- it('should not set selectedSingleValue for multi-valued attributes', () => {
- const configAttribute: Configurator.Attribute = {
- name: 'ATTRIBUTE_NAME',
- values: [
- { valueCode: 'VK1', selected: true },
- { valueCode: 'VK2', selected: true },
- ],
- };
- cpqConfiguratorNormalizer['setSelectedSingleValue'](configAttribute);
- expect(configAttribute.selectedSingleValue).toBeUndefined();
- });
+ it('should leave selectedSingleValue unset when more than one value is selected', () => {
+ const configAttribute: Configurator.Attribute = {
+ name: 'ATTRIBUTE_NAME',
+ values: [
+ { valueCode: 'VK1', selected: true },
+ { valueCode: 'VK2', selected: true },
+ ],
+ };
+ cpqConfiguratorNormalizer['setSelectedSingleValue'](configAttribute);
+ 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();
+ it('should leave selectedSingleValue unset when the 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', () => {
+ it('should mark single-selection attributes incomplete unless a value is selected', () => {
const attributeRBWithValues: Configurator.Attribute = {
name: 'ATTRIBUTE_NAME',
uiType: Configurator.UiType.RADIOBUTTON,
@@ -1079,7 +1125,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(attributeSSIWithValues.incomplete).toBe(false);
});
- it('should set incomplete by input type correctly', () => {
+ it('should mark string and numeric attributes incomplete unless userInput is set', () => {
const attributeStringWithValues: Configurator.Attribute = {
name: 'ATTRIBUTE_NAME',
uiType: Configurator.UiType.STRING,
@@ -1120,7 +1166,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(attributeNumericWoValues.incomplete).toBe(true);
});
- it('should set incomplete by checkbox, checkboxlist and multi-selection-image type correctly', () => {
+ it('should mark multi-select attributes incomplete unless at least one value is selected', () => {
const valuesWOSelectedOne: Configurator.Value[] = [
{ name: 'name1', selected: false, valueCode: cpqValueCode },
{ name: 'name2', selected: false, valueCode: cpqValueCode2 },
@@ -1187,7 +1233,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(attributeMSIWithValue.incomplete).toBe(false);
});
- it('should cover situation that multi select image attribute does not have values', () => {
+ it('should mark a multi-selection image incomplete when values are undefined', () => {
const attributeMSIWOValue: Configurator.Attribute = {
name: 'ATTRIBUTE_NAME',
uiType: Configurator.UiType.MULTI_SELECTION_IMAGE,
@@ -1198,13 +1244,342 @@ describe('CpqConfiguratorNormalizer', () => {
);
expect(attributeMSIWOValue.incomplete).toBe(true);
});
+
+ it('should mark a CONTAINER attribute complete when selected rows meet minRows', () => {
+ const attributeWithSelectedRow: Configurator.Attribute = {
+ name: 'ATTRIBUTE_NAME',
+ uiType: Configurator.UiType.CONTAINER,
+ container: { minRows: 1, rows: [{ id: '1', selected: true }] },
+ };
+
+ cpqConfiguratorNormalizer['compileAttributeIncomplete'](
+ attributeWithSelectedRow
+ );
+
+ expect(attributeWithSelectedRow.incomplete).toBe(false);
+ });
+
+ it('should mark a CONTAINER attribute incomplete when selected rows are below minRows', () => {
+ const attributeWithUnselectedRow: Configurator.Attribute = {
+ name: 'ATTRIBUTE_NAME',
+ uiType: Configurator.UiType.CONTAINER,
+ container: { minRows: 1, rows: [{ id: '1', selected: false }] },
+ };
+ const attributeWithEmptyRows: Configurator.Attribute = {
+ name: 'ATTRIBUTE_NAME',
+ uiType: Configurator.UiType.CONTAINER,
+ container: { minRows: 1, rows: [] },
+ };
+
+ cpqConfiguratorNormalizer['compileAttributeIncomplete'](
+ attributeWithUnselectedRow
+ );
+ cpqConfiguratorNormalizer['compileAttributeIncomplete'](
+ attributeWithEmptyRows
+ );
+
+ expect(attributeWithUnselectedRow.incomplete).toBe(true);
+ expect(attributeWithEmptyRows.incomplete).toBe(true);
+ });
+
+ it('should mark a CONTAINER attribute complete when minRows is omitted (defaults to 0)', () => {
+ const attributeWithoutContainer: Configurator.Attribute = {
+ name: 'ATTRIBUTE_NAME',
+ uiType: Configurator.UiType.CONTAINER,
+ };
+ const attributeWithUnselectedRow: Configurator.Attribute = {
+ name: 'ATTRIBUTE_NAME',
+ uiType: Configurator.UiType.CONTAINER,
+ container: { rows: [{ id: '1', selected: false }] },
+ };
+ cpqConfiguratorNormalizer['compileAttributeIncomplete'](
+ attributeWithoutContainer
+ );
+ cpqConfiguratorNormalizer['compileAttributeIncomplete'](
+ attributeWithUnselectedRow
+ );
+ expect(attributeWithoutContainer.incomplete).toBe(false);
+ expect(attributeWithUnselectedRow.incomplete).toBe(false);
+ });
+
+ it('should mark a CONTAINER attribute incomplete when a row minRows is not met', () => {
+ const attributeWithRowMinRows: Configurator.Attribute = {
+ name: 'ATTRIBUTE_NAME',
+ uiType: Configurator.UiType.CONTAINER,
+ container: {
+ minRows: 0,
+ rows: [
+ { id: '1', productSystemId: 'P1', selected: true },
+ { id: '2', productSystemId: 'P2', selected: false, minRows: 2 },
+ ],
+ },
+ };
+
+ cpqConfiguratorNormalizer['compileAttributeIncomplete'](
+ attributeWithRowMinRows
+ );
+
+ expect(attributeWithRowMinRows.incomplete).toBe(true);
+ });
+
+ it('should mark a CONTAINER attribute complete when row minRows is met', () => {
+ const attributeWithRowMinRows: Configurator.Attribute = {
+ name: 'ATTRIBUTE_NAME',
+ uiType: Configurator.UiType.CONTAINER,
+ container: {
+ minRows: 0,
+ rows: [
+ { id: '1', productSystemId: 'P2', selected: true, minRows: 2 },
+ { id: '2', productSystemId: 'P2', selected: true },
+ ],
+ },
+ };
+
+ cpqConfiguratorNormalizer['compileAttributeIncomplete'](
+ attributeWithRowMinRows
+ );
+
+ expect(attributeWithRowMinRows.incomplete).toBe(false);
+ });
+
+ it('should evaluate container and per-product row minRows independently', () => {
+ const rows: Configurator.ContainerRow[] = [
+ {
+ id: 'A',
+ productSystemId: 'PROD_A',
+ minRows: 2,
+ maxRows: 5,
+ selected: true,
+ },
+ { id: 'A2', productSystemId: 'PROD_A', selected: true },
+ { id: 'B', productSystemId: 'PROD_B', maxRows: 10, selected: true },
+ {
+ id: 'C',
+ productSystemId: 'PROD_C',
+ minRows: 3,
+ selected: true,
+ },
+ { id: 'C2', productSystemId: 'PROD_C', selected: true },
+ { id: 'C3', productSystemId: 'PROD_C', selected: true },
+ { id: 'X', productSystemId: 'PROD_X', selected: true },
+ ];
+ const completeAttribute: Configurator.Attribute = {
+ name: 'ATTRIBUTE_NAME',
+ uiType: Configurator.UiType.CONTAINER,
+ container: { minRows: 7, maxRows: 10, rows },
+ };
+ const belowContainerMinRows: Configurator.Attribute = {
+ ...completeAttribute,
+ container: {
+ ...completeAttribute.container!,
+ rows: rows.slice(0, 6),
+ },
+ };
+ const belowRowAMinRows: Configurator.Attribute = {
+ ...completeAttribute,
+ container: {
+ ...completeAttribute.container!,
+ rows: [
+ {
+ id: 'A',
+ productSystemId: 'PROD_A',
+ minRows: 2,
+ maxRows: 5,
+ selected: true,
+ },
+ ...rows.slice(2),
+ ],
+ },
+ };
+ const belowRowCMinRows: Configurator.Attribute = {
+ ...completeAttribute,
+ container: {
+ ...completeAttribute.container!,
+ rows: rows.slice(0, 5),
+ },
+ };
+
+ cpqConfiguratorNormalizer['compileAttributeIncomplete'](
+ completeAttribute
+ );
+ cpqConfiguratorNormalizer['compileAttributeIncomplete'](
+ belowContainerMinRows
+ );
+ cpqConfiguratorNormalizer['compileAttributeIncomplete'](belowRowAMinRows);
+ cpqConfiguratorNormalizer['compileAttributeIncomplete'](belowRowCMinRows);
+
+ expect(completeAttribute.incomplete).toBe(false);
+ expect(belowContainerMinRows.incomplete).toBe(true);
+ expect(belowRowAMinRows.incomplete).toBe(true);
+ expect(belowRowCMinRows.incomplete).toBe(true);
+ });
+ });
+
+ describe('compileGroupComplete', () => {
+ it('should set group complete to false when an attribute is incomplete', () => {
+ const group: Configurator.Group = {
+ id: '1',
+ complete: true,
+ consistent: true,
+ subGroups: [],
+ attributes: [
+ {
+ name: 'ATTRIBUTE_NAME',
+ incomplete: true,
+ },
+ ],
+ };
+
+ cpqConfiguratorNormalizer['compileGroupComplete'](group);
+
+ expect(group.complete).toBe(false);
+ });
+
+ it('should set group complete to false when a sub group is incomplete', () => {
+ const group: Configurator.Group = {
+ id: '1',
+ complete: true,
+ consistent: true,
+ subGroups: [
+ {
+ id: '2',
+ complete: false,
+ consistent: true,
+ subGroups: [],
+ },
+ ],
+ };
+
+ cpqConfiguratorNormalizer['compileGroupComplete'](group);
+
+ expect(group.complete).toBe(false);
+ });
+
+ it('should propagate incompleteness to ancestor groups', () => {
+ const rootGroup: Configurator.Group = {
+ id: '1',
+ complete: true,
+ consistent: true,
+ subGroups: [],
+ };
+ const rowGroup: Configurator.Group = {
+ id: '2',
+ complete: true,
+ consistent: true,
+ subGroups: [],
+ };
+ const nestedGroup: Configurator.Group = {
+ id: '3',
+ complete: true,
+ consistent: true,
+ subGroups: [],
+ attributes: [
+ {
+ name: 'ATTRIBUTE_NAME',
+ incomplete: true,
+ },
+ ],
+ };
+
+ cpqConfiguratorNormalizer['compileGroupComplete'](nestedGroup, [
+ rowGroup,
+ rootGroup,
+ ]);
+
+ expect(nestedGroup.complete).toBe(false);
+ expect(rowGroup.complete).toBe(false);
+ expect(rootGroup.complete).toBe(false);
+ });
+
+ it('should propagate incompleteness down to a single sub group', () => {
+ const nestedGroup: Configurator.Group = {
+ id: '3',
+ complete: true,
+ consistent: true,
+ subGroups: [],
+ };
+ const rowGroup: Configurator.Group = {
+ id: '2',
+ complete: false,
+ consistent: true,
+ subGroups: [nestedGroup],
+ };
+
+ cpqConfiguratorNormalizer['compileGroupComplete'](rowGroup);
+
+ expect(nestedGroup.complete).toBe(false);
+ });
+
+ it('should not propagate incompleteness down when there are multiple sub groups', () => {
+ const nestedGroupA: Configurator.Group = {
+ id: '3',
+ complete: true,
+ consistent: true,
+ subGroups: [],
+ };
+ const nestedGroupB: Configurator.Group = {
+ id: '4',
+ complete: true,
+ consistent: true,
+ subGroups: [],
+ };
+ const rowGroup: Configurator.Group = {
+ id: '2',
+ complete: false,
+ consistent: true,
+ subGroups: [nestedGroupA, nestedGroupB],
+ };
+
+ cpqConfiguratorNormalizer['compileGroupComplete'](rowGroup);
+
+ expect(nestedGroupA.complete).toBe(true);
+ expect(nestedGroupB.complete).toBe(true);
+ });
+
+ it('should leave group complete when no attribute is incomplete', () => {
+ const group: Configurator.Group = {
+ id: '1',
+ complete: true,
+ consistent: true,
+ subGroups: [],
+ attributes: [
+ {
+ name: 'ATTRIBUTE_NAME',
+ incomplete: false,
+ },
+ ],
+ };
+
+ cpqConfiguratorNormalizer['compileGroupComplete'](group);
+
+ expect(group.complete).toBe(true);
+ });
+
+ it('should leave group incomplete when no attribute is incomplete', () => {
+ const group: Configurator.Group = {
+ id: '1',
+ complete: false,
+ consistent: true,
+ subGroups: [],
+ attributes: [
+ {
+ name: 'ATTRIBUTE_NAME',
+ incomplete: false,
+ },
+ ],
+ };
+
+ cpqConfiguratorNormalizer['compileGroupComplete'](group);
+
+ expect(group.complete).toBe(false);
+ });
});
describe('hasValueToBeIgnored', () => {
const cpqValueA: Cpq.Value = { paV_ID: 0, selected: false };
const cpqValueB: Cpq.Value = { paV_ID: 1, selected: true };
- it('should deal with situation that required is undefined in attribute', () => {
+ it('should not ignore the retract option when required is undefined', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1216,7 +1591,7 @@ describe('CpqConfiguratorNormalizer', () => {
).toBe(false);
});
- it('should determine the "No option selected" value for required DDLB as "to be ignored" when a "real" value already selected', () => {
+ it('should ignore the retract option on a required dropdown that already has a real selection', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1229,7 +1604,7 @@ describe('CpqConfiguratorNormalizer', () => {
).toBe(true);
});
- it('should determine the "No option selected" value for non required DDLB as not "to be ignored" when a "real" value already selected', () => {
+ it('should keep the retract option on a non-required dropdown that already has a real selection', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1242,7 +1617,7 @@ describe('CpqConfiguratorNormalizer', () => {
).toBe(false);
});
- it('should determine the "No option selected" value for not DDLB as not "to be ignored" when a "real" value already selected', () => {
+ it('should keep the retract option on a required radio button', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1255,7 +1630,7 @@ describe('CpqConfiguratorNormalizer', () => {
).toBe(false);
});
- it('should determine the "No option selected" value for required DDLB as not "to be ignored" when no "real" value already selected', () => {
+ it('should keep the retract option on a required dropdown with no real selection', () => {
const cpqValueB: Cpq.Value = { paV_ID: 1, selected: false };
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
@@ -1269,7 +1644,7 @@ describe('CpqConfiguratorNormalizer', () => {
).toBe(false);
});
- it('should determine the "real" value for required DDLB as not "to be ignored" when another "real" value already selected', () => {
+ it('should never ignore a real (non-retract) dropdown value', () => {
const cpqValueA: Cpq.Value = { paV_ID: 2, selected: false };
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
@@ -1285,7 +1660,7 @@ describe('CpqConfiguratorNormalizer', () => {
});
describe('getTabAttributes', () => {
- it('should return tab attributes when version supports full tab payload', () => {
+ it("should use the tab's own attributes when hasFullConfigurationState is true", () => {
const source: Cpq.Configuration = {
...cpqConfiguration,
hasFullConfigurationState: true,
@@ -1299,7 +1674,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result).toEqual([cpqAttribute2]);
});
- it('should return global source attributes for selected tab when version is not defined', () => {
+ it('should use configuration.attributes for the selected tab when hasFullConfigurationState is undefined', () => {
const source: Cpq.Configuration = {
...cpqConfiguration,
hasFullConfigurationState: undefined,
@@ -1313,7 +1688,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result).toEqual(source.attributes);
});
- it('should return empty array for non-selected tab when version is not defined', () => {
+ it('should return no attributes for an unselected tab when hasFullConfigurationState is false', () => {
const source: Cpq.Configuration = {
...cpqConfiguration,
hasFullConfigurationState: false,
@@ -1327,15 +1702,6 @@ describe('CpqConfiguratorNormalizer', () => {
});
});
- describe('generateErrorMessages', () => {
- it('should create no error message for incomplete attribute', () => {
- const messageObs = cpqConfiguratorNormalizer['generateErrorMessages'](
- cpqConfigurationIncompleteConsistent
- );
- expect(messageObs.length).toBe(0);
- });
- });
-
function checkMessagePresent(messages?: string[], expectedMsg?: string) {
if (messages && expectedMsg) {
expect(messages.includes(expectedMsg)).toBeTruthy();
@@ -1360,7 +1726,7 @@ describe('CpqConfiguratorNormalizer', () => {
};
const values: Configurator.Value[] = [];
- it('should convert value display - contain cpq value display for radio-buttons', () => {
+ it('should keep the CPQ valueDisplay for radio buttons', () => {
cpqConfiguratorNormalizer['convertValue'](
mockCpqValue,
cpqAttr,
@@ -1377,7 +1743,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(value.valueDisplay).toEqual(mockCpqValue.valueDisplay);
});
- it('should convert value display - contain drop-down select message', () => {
+ it('should use the drop-down select message for a selected retract option', () => {
const mockCpqValue: Cpq.Value = {
paV_ID: 0,
valueDisplay: 'No option selected',
@@ -1408,7 +1774,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should convert value display - contain cpq value display for selected real drop-down value (paV_ID not 0)', () => {
+ it('should keep the CPQ valueDisplay for a selected real dropdown value', () => {
const mockCpqValue: Cpq.Value = {
paV_ID: 5,
valueDisplay: 'Red',
@@ -1432,7 +1798,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(value.valueDisplay).toEqual(mockCpqValue.valueDisplay);
});
- it('should convert value display - contain cpq value display for drop-down list', () => {
+ it('should keep the CPQ valueDisplay for an unselected dropdown value', () => {
cpqConfiguratorNormalizer['convertValue'](
mockCpqValue,
cpqAttr,
@@ -1450,7 +1816,7 @@ describe('CpqConfiguratorNormalizer', () => {
});
describe('isUITypeReadOnly', () => {
- it('should return true for READ_ONLY ui type', () => {
+ it('should identify READ_ONLY as a read-only ui type', () => {
const attribute: Configurator.Attribute = {
name: 'ATTRIBUTE_NAME',
uiType: Configurator.UiType.READ_ONLY,
@@ -1460,7 +1826,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return false for non READ_ONLY ui type', () => {
+ it('should not identify a selectable ui type such as RADIOBUTTON as read-only', () => {
const attribute: Configurator.Attribute = {
name: 'ATTRIBUTE_NAME',
uiType: Configurator.UiType.RADIOBUTTON,
@@ -1485,47 +1851,47 @@ describe('CpqConfiguratorNormalizer', () => {
).toBe(expected);
}
- it('should return true for RADIOBUTTON ui type', () => {
+ it('should treat RADIOBUTTON as a single-selection ui type', () => {
expectSingleSelectionUiType(Configurator.UiType.RADIOBUTTON, true);
});
- it('should return true for DROPDOWN ui type', () => {
+ it('should treat DROPDOWN as a single-selection ui type', () => {
expectSingleSelectionUiType(Configurator.UiType.DROPDOWN, true);
});
- it('should return false for SINGLE_SELECTION_IMAGE ui type', () => {
+ it('should not treat SINGLE_SELECTION_IMAGE as a single-selection ui type', () => {
expectSingleSelectionUiType(
Configurator.UiType.SINGLE_SELECTION_IMAGE,
false
);
});
- it('should return true for DROPDOWN_PRODUCT ui type', () => {
+ it('should treat DROPDOWN_PRODUCT as a single-selection ui type', () => {
expectSingleSelectionUiType(Configurator.UiType.DROPDOWN_PRODUCT, true);
});
- it('should return true for RADIOBUTTON_PRODUCT ui type', () => {
+ it('should treat RADIOBUTTON_PRODUCT as a single-selection ui type', () => {
expectSingleSelectionUiType(
Configurator.UiType.RADIOBUTTON_PRODUCT,
true
);
});
- it('should return false for CHECKBOX ui type', () => {
+ it('should not treat CHECKBOXLIST as a single-selection ui type', () => {
expectSingleSelectionUiType(Configurator.UiType.CHECKBOXLIST, false);
});
- it('should return false for STRING ui type', () => {
+ it('should not treat STRING as a single-selection ui type', () => {
expectSingleSelectionUiType(Configurator.UiType.STRING, false);
});
- it('should return false for READ_ONLY ui type', () => {
+ it('should not treat READ_ONLY as a single-selection ui type', () => {
expectSingleSelectionUiType(Configurator.UiType.READ_ONLY, false);
});
});
describe('isNoValueSelected', () => {
- it('should return true when no value is selected', () => {
+ it('should treat the attribute as having no selection when every value is unselected', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1539,14 +1905,14 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should return true when values are undefined', () => {
+ it('should treat the attribute as having no selection when it has no values array', () => {
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', () => {
+ it('should treat the attribute as having a selection when any value is selected', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1562,7 +1928,7 @@ describe('CpqConfiguratorNormalizer', () => {
});
describe('hasRetractValue', () => {
- it('should return true when a value with paV_ID 0 (retract) is present', () => {
+ it('should detect a retract option when a value with paV_ID 0 exists', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1574,7 +1940,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(cpqConfiguratorNormalizer['hasRetractValue'](cpqAttr)).toBe(true);
});
- it('should return false when no value with paV_ID 0 is present', () => {
+ it('should not detect a retract option when only real values exist', () => {
const cpqAttr: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1586,14 +1952,14 @@ describe('CpqConfiguratorNormalizer', () => {
expect(cpqConfiguratorNormalizer['hasRetractValue'](cpqAttr)).toBe(false);
});
- it('should return false when values are undefined', () => {
+ it('should not detect a retract option when the attribute has no values array', () => {
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', () => {
+ it('should show the drop-down select prompt for a selected DROPDOWN retract value', () => {
const value: Configurator.Value = { valueCode: '0', selected: true };
const attribute: Configurator.Attribute = {
name: 'attr_1',
@@ -1606,7 +1972,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should use drop-down select message for selected DROPDOWN_PRODUCT', () => {
+ it('should show the drop-down select prompt for a selected DROPDOWN_PRODUCT retract value', () => {
const value: Configurator.Value = { valueCode: '0', selected: true };
const attribute: Configurator.Attribute = {
name: 'attr_2',
@@ -1619,7 +1985,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should use no option selected message for non-selected DROPDOWN', () => {
+ it('should show "no option selected" for an unselected DROPDOWN retract value', () => {
const value: Configurator.Value = { valueCode: '0', selected: false };
const attribute: Configurator.Attribute = {
name: 'attr_2',
@@ -1632,7 +1998,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should use drop-down select message for non-selected DROPDOWN_PRODUCT', () => {
+ it('should show "no option selected" for an unselected DROPDOWN_PRODUCT retract value', () => {
const value: Configurator.Value = { valueCode: '0', selected: false };
const attribute: Configurator.Attribute = {
name: 'attr_2',
@@ -1645,7 +2011,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should use no option selected message for non-dropdown types', () => {
+ it('should show "no option selected" for a selected RADIOBUTTON retract value', () => {
const value: Configurator.Value = { valueCode: '0', selected: true };
const attribute: Configurator.Attribute = {
name: 'attr_3',
@@ -1660,7 +2026,7 @@ describe('CpqConfiguratorNormalizer', () => {
});
describe('addRetractValue', () => {
- it('should add a retract value for not required single selection ui types', () => {
+ it('should add a selected retract option for optional radio and dropdown attributes', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1690,7 +2056,7 @@ describe('CpqConfiguratorNormalizer', () => {
});
});
- it('should not add a retract value for not required SINGLE_SELECTION_IMAGE attributes', () => {
+ it('should not add a retract option for optional SINGLE_SELECTION_IMAGE attributes', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1711,7 +2077,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values.length).toBe(0);
});
- it('should not add a retract value for required drop-down ui types', () => {
+ it('should not add a retract option for required dropdown attributes', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1737,7 +2103,7 @@ describe('CpqConfiguratorNormalizer', () => {
});
});
- it('should not add a retract value for required RADIOBUTTON attributes', () => {
+ it('should not add a retract option for required RADIOBUTTON attributes', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1758,7 +2124,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values.length).toBe(0);
});
- it('should not add a retract value for READ_ONLY attributes', () => {
+ it('should not add a retract option when the attribute is READ_ONLY', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1777,7 +2143,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values.length).toBe(0);
});
- it('should not add a retract value when a retract value (paV_ID 0) is already present', () => {
+ it('should not add another retract option if CPQ already sent paV_ID 0', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1796,7 +2162,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values.length).toBe(0);
});
- it('should not add a retract value for unsupported ui types', () => {
+ it('should not add a retract option for multi-select ui types such as CHECKBOXLIST', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1817,7 +2183,7 @@ describe('CpqConfiguratorNormalizer', () => {
});
describe('addRequiredSelectionPromptValue', () => {
- it('should add a documentation value for required drop-down ui types when no value is selected', () => {
+ it('should add a prompt option for a required dropdown with no selection', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1845,7 +2211,7 @@ describe('CpqConfiguratorNormalizer', () => {
});
});
- it('should not add a documentation value for required drop-down ui types when a value is selected', () => {
+ it('should not add a prompt option when a required dropdown already has a selection', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1866,7 +2232,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values.length).toBe(0);
});
- it('should not add a documentation value for not required drop-down ui types', () => {
+ it('should not add a prompt option for an optional dropdown', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1887,7 +2253,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values.length).toBe(0);
});
- it('should not add a documentation value for required RADIOBUTTON attributes', () => {
+ it('should not add a prompt option for a required RADIOBUTTON', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1908,7 +2274,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values.length).toBe(0);
});
- it('should not add a documentation value for READ_ONLY attributes', () => {
+ it('should not add a prompt option when the attribute is READ_ONLY', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1929,7 +2295,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(values.length).toBe(0);
});
- it('should not add a documentation value when a retract value (paV_ID 0) is already present', () => {
+ it('should not add a prompt option if CPQ already sent paV_ID 0', () => {
const sourceAttribute: Cpq.Attribute = {
pA_ID: 1,
stdAttrCode: 2,
@@ -1952,13 +2318,13 @@ describe('CpqConfiguratorNormalizer', () => {
});
describe('generateWarningMessages', () => {
- it('should return empty array when no warnings are present', () => {
+ it('should return an empty list when there are no failed validations or incomplete messages', () => {
const result =
cpqConfiguratorNormalizer['generateWarningMessages'](cpqConfiguration);
expect(result).toEqual([]);
});
- it('should concat failed validations and incomplete messages', () => {
+ it('should concatenate failedValidations and incompleteMessages', () => {
const result = cpqConfiguratorNormalizer['generateWarningMessages']({
...cpqConfiguration,
failedValidations: [VALIDATION_MSG],
@@ -1968,8 +2334,32 @@ describe('CpqConfiguratorNormalizer', () => {
});
});
+ describe('generateErrorMessages', () => {
+ it('should not treat incomplete attributes as error messages', () => {
+ const messageObs = cpqConfiguratorNormalizer['generateErrorMessages'](
+ cpqConfigurationIncompleteConsistent
+ );
+ expect(messageObs.length).toBe(0);
+ });
+
+ it('should return an empty list when there are no error or invalid messages', () => {
+ const result =
+ cpqConfiguratorNormalizer['generateErrorMessages'](cpqConfiguration);
+ expect(result).toEqual([]);
+ });
+
+ it('should concatenate errorMessages and invalidMessages', () => {
+ const result = cpqConfiguratorNormalizer['generateErrorMessages']({
+ ...cpqConfiguration,
+ errorMessages: [ERROR_MSG],
+ invalidMessages: [INVALID_MSG],
+ });
+ expect(result).toEqual([ERROR_MSG, INVALID_MSG]);
+ });
+ });
+
describe('generateTotalNumberOfIssues', () => {
- it('should return 0 when no issues are present', () => {
+ it('should return 0 for a complete, consistent configuration', () => {
expect(
cpqConfiguratorNormalizer['generateTotalNumberOfIssues'](
cpqConfiguration
@@ -1977,7 +2367,7 @@ describe('CpqConfiguratorNormalizer', () => {
).toBe(0);
});
- it('should sum up all issue sources', () => {
+ it('should add incomplete attributes, conflicts, errors, invalid messages, failed validations and incomplete messages', () => {
expect(
cpqConfiguratorNormalizer['generateTotalNumberOfIssues'](
cpqConfigurationIncompleteInconsistent
@@ -1987,7 +2377,7 @@ describe('CpqConfiguratorNormalizer', () => {
});
describe('mapPAId', () => {
- it("should map standard field name 'pA_ID' if present", () => {
+ it('should prefer pA_ID when both pA_ID and PA_ID are present', () => {
expect(
cpqConfiguratorNormalizer['mapPAId']({
pA_ID: 123,
@@ -1995,7 +2385,7 @@ describe('CpqConfiguratorNormalizer', () => {
})
).toBe('123');
});
- it("should map fallback field name 'PA_ID' if standard field name 'pA_ID' is not present", () => {
+ it('should fall back to PA_ID when pA_ID is missing', () => {
expect(
cpqConfiguratorNormalizer['mapPAId']({
PA_ID: 456,
@@ -2056,7 +2446,7 @@ describe('CpqConfiguratorNormalizer', () => {
stdAttrCode: cpqAttributeStdAttrCode,
minRows: 1,
maxRows: 15,
- failedValidations: ['Too many units'],
+ messages: [{ message: 'Too many units' }],
rows: [
{
id: rowWithoutConfigId,
@@ -2077,6 +2467,10 @@ describe('CpqConfiguratorNormalizer', () => {
],
configuration: {
completed: false,
+ errorMessages: [ERROR_MSG],
+ invalidMessages: [INVALID_MSG],
+ failedValidations: [VALIDATION_MSG],
+ incompleteMessages: [INCOMPLETE_MSG],
messages: [
{
message: 'Check zoom range',
@@ -2109,7 +2503,36 @@ describe('CpqConfiguratorNormalizer', () => {
};
}
- it('should leave attribute without container when no matching sapContainers entry exists', () => {
+ function convertContainerAttribute(
+ required: boolean,
+ minRows?: number
+ ): Configurator.Attribute | undefined {
+ return cpqConfiguratorNormalizer.convert({
+ ...cpqConfiguration,
+ tabs: [
+ {
+ ...cpqTab,
+ attributes: [
+ {
+ ...cpqAttribute,
+ displayAs: Cpq.DisplayAs.CONTAINER,
+ required,
+ values: [],
+ },
+ ],
+ },
+ ],
+ sapContainers: [
+ {
+ stdAttrCode: cpqAttributeStdAttrCode,
+ minRows,
+ rows: [],
+ },
+ ],
+ }).groups[0].attributes?.[0];
+ }
+
+ it('should not attach a container when no sapContainers entry matches the attribute code', () => {
const result = cpqConfiguratorNormalizer.convert(
configurationWithContainers([
{
@@ -2122,14 +2545,14 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.groups[0].subGroups.length).toBe(0);
});
- it('should attach matching container metadata and row actions to the attribute', () => {
+ it('should map minRows, maxRows, rows and add-actions onto the matching attribute', () => {
const result = cpqConfiguratorNormalizer.convert(
configurationWithContainers([
{
stdAttrCode: cpqAttributeStdAttrCode,
minRows: 2,
maxRows: 5,
- failedValidations: ['validation'],
+ messages: [{ message: 'validation' }],
rows: [
{
id: '1',
@@ -2145,7 +2568,7 @@ describe('CpqConfiguratorNormalizer', () => {
const container = result.groups[0].attributes?.[0].container;
expect(container?.minRows).toBe(2);
expect(container?.maxRows).toBe(5);
- expect(container?.failedValidations).toEqual(['validation']);
+ //expect(container?.failedValidations).toEqual(['validation']);
expect(container?.rows.length).toBe(1);
expect(container?.rows[0]).toEqual(
jasmine.objectContaining({
@@ -2160,7 +2583,147 @@ describe('CpqConfiguratorNormalizer', () => {
expect(result.groups[0].subGroups.length).toBe(0);
});
- it('should create CONTAINER_ROW_GROUP for rows with nested configuration', () => {
+ it('should mark a CONTAINER attribute complete when selected rows meet minRows (or minRows is omitted)', () => {
+ const result = cpqConfiguratorNormalizer.convert({
+ ...cpqConfiguration,
+ tabs: [
+ {
+ ...cpqTab,
+ attributes: [
+ {
+ ...cpqAttribute,
+ displayAs: Cpq.DisplayAs.CONTAINER,
+ values: [],
+ },
+ ],
+ },
+ ],
+ sapContainers: [
+ {
+ stdAttrCode: cpqAttributeStdAttrCode,
+ rows: [{ id: '1', selected: true }],
+ },
+ ],
+ });
+ expect(result.groups[0].attributes?.[0].incomplete).toBe(false);
+ });
+
+ it('should mark a CONTAINER attribute incomplete when selected rows are below minRows', () => {
+ const result = cpqConfiguratorNormalizer.convert({
+ ...cpqConfiguration,
+ tabs: [
+ {
+ ...cpqTab,
+ attributes: [
+ {
+ ...cpqAttribute,
+ displayAs: Cpq.DisplayAs.CONTAINER,
+ values: [],
+ },
+ ],
+ },
+ ],
+ sapContainers: [
+ {
+ stdAttrCode: cpqAttributeStdAttrCode,
+ minRows: 1,
+ rows: [{ id: '1', selected: false }],
+ },
+ ],
+ });
+ expect(result.groups[0].attributes?.[0].incomplete).toBe(true);
+ expect(result.groups[0].complete).toBe(false);
+ });
+
+ it('should force required=true when minRows is 1 even if the CPQ attribute is optional', () => {
+ expect(convertContainerAttribute(false, 1)?.required).toBe(true);
+ });
+
+ it('should force required=true when minRows is greater than 1 even if the CPQ attribute is optional', () => {
+ expect(convertContainerAttribute(false, 2)?.required).toBe(true);
+ });
+
+ it('should leave an optional CPQ attribute optional when minRows is 0', () => {
+ expect(convertContainerAttribute(false, 0)?.required).toBe(false);
+ });
+
+ it('should leave an optional CPQ attribute optional when minRows is omitted', () => {
+ expect(convertContainerAttribute(false)?.required).toBe(false);
+ });
+
+ it('should force required=true when a row minRows is at least 1 even if the container minRows is 0', () => {
+ const attribute = cpqConfiguratorNormalizer.convert({
+ ...cpqConfiguration,
+ tabs: [
+ {
+ ...cpqTab,
+ attributes: [
+ {
+ ...cpqAttribute,
+ displayAs: Cpq.DisplayAs.CONTAINER,
+ required: false,
+ values: [],
+ },
+ ],
+ },
+ ],
+ sapContainers: [
+ {
+ stdAttrCode: cpqAttributeStdAttrCode,
+ minRows: 0,
+ rows: [
+ {
+ id: '1',
+ productSystemId: 'P1',
+ minRows: 2,
+ },
+ ],
+ },
+ ],
+ }).groups[0].attributes?.[0];
+
+ expect(attribute?.required).toBe(true);
+ });
+
+ it('should leave an optional CPQ attribute optional when neither container nor row minRows is set', () => {
+ const attribute = cpqConfiguratorNormalizer.convert({
+ ...cpqConfiguration,
+ tabs: [
+ {
+ ...cpqTab,
+ attributes: [
+ {
+ ...cpqAttribute,
+ displayAs: Cpq.DisplayAs.CONTAINER,
+ required: false,
+ values: [],
+ },
+ ],
+ },
+ ],
+ sapContainers: [
+ {
+ stdAttrCode: cpqAttributeStdAttrCode,
+ minRows: 0,
+ rows: [
+ {
+ id: '1',
+ productSystemId: 'P1',
+ maxRows: 10,
+ },
+ ],
+ },
+ ],
+ }).groups[0].attributes?.[0];
+
+ expect(attribute?.required).toBe(false);
+ });
+
+ it('should keep a required CPQ attribute required when minRows is 0', () => {
+ expect(convertContainerAttribute(true, 0)?.required).toBe(true);
+ });
+
+ it('should create a CONTAINER_ROW_GROUP with nested tab, messages and row groupId', () => {
const result = cpqConfiguratorNormalizer.convert(
configurationWithContainers([containerWithRows])
);
@@ -2187,14 +2750,15 @@ describe('CpqConfiguratorNormalizer', () => {
expect(rowGroup.name).toBe('LENS_ZOOM');
expect(rowGroup.description).toBe('Zoom Lens');
expect(rowGroup.complete).toBe(false);
+ expect(parentGroup.complete).toBe(false);
expect(rowGroup.messages).toEqual([
{
message: 'Check zoom range',
- severity: Configurator.MessageSeverity.WARNING,
+ severity: Configurator.MessageSeverity.ERROR,
},
{
message: 'Info only',
- severity: Configurator.MessageSeverity.INFO,
+ severity: Configurator.MessageSeverity.WARNING,
},
]);
@@ -2212,9 +2776,73 @@ describe('CpqConfiguratorNormalizer', () => {
expectedNestedTabGroupId
);
expect(nestedAttrGroup.attributes?.[0].attrCode).toBe(nestedAttrCode);
+ expect(nestedAttrGroup.complete).toBe(false);
+ });
+
+ it('should include nested container-row issues in totalNumberOfIssues', () => {
+ const result = cpqConfiguratorNormalizer.convert(
+ configurationWithContainers([containerWithRows])
+ );
+ expect(result.totalNumberOfIssues).toBe(6);
+ });
+
+ it('should include incomplete nested-tab attributes in totalNumberOfIssues', () => {
+ const nestedAttributeIncomplete: Cpq.Attribute = {
+ ...nestedAttribute,
+ incomplete: true,
+ };
+ const containerWithIncompleteNestedAttribute: Cpq.Container = {
+ ...containerWithRows,
+ rows: containerWithRows.rows?.map((row) =>
+ row.id === rowWithConfigId
+ ? {
+ ...row,
+ configuration: {
+ ...row.configuration,
+ tabs: [
+ {
+ ...nestedTab,
+ attributes: [nestedAttributeIncomplete],
+ },
+ ],
+ },
+ }
+ : row
+ ),
+ };
+ const result = cpqConfiguratorNormalizer.convert(
+ configurationWithContainers([containerWithIncompleteNestedAttribute])
+ );
+ expect(result.totalNumberOfIssues).toBe(7);
+ });
+
+ it('should leave row-group messages undefined when the nested configuration has none', () => {
+ const result = cpqConfiguratorNormalizer.convert(
+ configurationWithContainers([
+ {
+ stdAttrCode: cpqAttributeStdAttrCode,
+ rows: [
+ {
+ id: rowWithConfigId,
+ productSystemId: 'LENS_ZOOM',
+ selected: true,
+ configuration: {
+ completed: true,
+ tabs: [nestedTab],
+ },
+ },
+ ],
+ },
+ ])
+ );
+ const row = result.groups[0].attributes?.[0].container?.rows[0];
+ expect(row?.groupId).toBe(
+ `${Configurator.ContainerRowGroupIdPrefix}@${cpqAttributeStdAttrCode}@${rowWithConfigId}`
+ );
+ expect(result.groups[0].subGroups[0].messages).toBeUndefined();
});
- it('should recurse nested containers and keep CONTAINER_ROW_GROUP out of flatGroups', () => {
+ it('should attach nested containers and keep CONTAINER_ROW_GROUP out of flatGroups', () => {
const result = cpqConfiguratorNormalizer.convert(
configurationWithContainers([containerWithRows])
);
@@ -2238,7 +2866,7 @@ describe('CpqConfiguratorNormalizer', () => {
).toBe(true);
});
- it('should list a nested tab after its parent tab in flatGroups', () => {
+ it('should append a nested container-row tab after its parent in flatGroups', () => {
const result = cpqConfiguratorNormalizer.convert(
configurationWithContainers([containerWithRows])
);
@@ -2248,7 +2876,7 @@ describe('CpqConfiguratorNormalizer', () => {
);
});
- it('should keep nested tab group IDs unique when CPQ reuses a root tab ID', () => {
+ it('should prefix a nested tab id with the row group id when CPQ reuses a root tab id', () => {
const result = cpqConfiguratorNormalizer.convert(
configurationWithContainers([
{
@@ -2278,7 +2906,7 @@ describe('CpqConfiguratorNormalizer', () => {
expect(new Set(flatGroupIds).size).toBe(flatGroupIds.length);
});
- it('should attach sapContainers on the generic-group path when no tabs exist', () => {
+ it('should attach matching sapContainers when convert falls back to the generic group', () => {
const result = cpqConfiguratorNormalizer.convert({
productSystemId: cpqProductSystemId,
currencyISOCode: CURRENCY,
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 a07229daa74..977b1eb3ea4 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
@@ -21,6 +21,12 @@ export class CpqConfiguratorNormalizer
protected translation: TranslationService
) {}
+ /**
+ * Converts a CPQ configuration to the configurator-independent model.
+ *
+ * @param source - CPQ configuration
+ * @param target - optional target configuration to be filled
+ */
convert(
source: Cpq.Configuration,
target?: Configurator.Configuration
@@ -44,6 +50,8 @@ export class CpqConfiguratorNormalizer
interactionState: {},
errorMessages: this.generateErrorMessages(source),
warningMessages: this.generateWarningMessages(source),
+ messages: this.convertMessages(source.messages),
+ hasFullConfigurationState: source.hasFullConfigurationState,
pricingEnabled: true,
};
@@ -73,15 +81,17 @@ export class CpqConfiguratorNormalizer
}
protected generateTotalNumberOfIssues(source: Cpq.Configuration): number {
- return (
- (source.incompleteAttributes?.length ?? 0) +
- (source.incompleteMessages?.length ?? 0) +
- (source.invalidMessages?.length ?? 0) +
- (source.failedValidations?.length ?? 0) +
- (source.errorMessages?.length ?? 0)
+ return this.cpqConfiguratorNormalizerUtilsService.calculateTotalNumberOfIssues(
+ source
);
}
+ /**
+ * Collects warning messages from failed validations and incomplete messages.
+ *
+ * @param source - CPQ configuration
+ * @returns Warning messages
+ */
protected generateWarningMessages(source: Cpq.Configuration): string[] {
return [
...(source.failedValidations ?? []),
@@ -89,6 +99,12 @@ export class CpqConfiguratorNormalizer
];
}
+ /**
+ * Collects error messages from error and invalid messages. *
+ *
+ * @param source - CPQ configuration
+ * @returns Error messages
+ */
protected generateErrorMessages(source: Cpq.Configuration): string[] {
return [...(source.errorMessages ?? []), ...(source.invalidMessages ?? [])];
}
@@ -101,7 +117,9 @@ export class CpqConfiguratorNormalizer
flatGroupList: Configurator.Group[],
containers?: Cpq.Container[],
containerRowId?: string,
- parentRowGroupId?: string
+ parentRowGroupId?: string,
+ parentGroup?: Configurator.Group,
+ ancestorGroups: Configurator.Group[] = []
) {
const groupId = this.createTabGroupId(source.id, parentRowGroupId);
const attributes: Configurator.Attribute[] = [];
@@ -134,6 +152,8 @@ export class CpqConfiguratorNormalizer
groupList.push(group);
this.attachContainers(group, containers, currency, flatGroupList);
+ const ancestors = parentGroup ? [parentGroup, ...ancestorGroups] : [];
+ this.compileGroupComplete(group, ancestors);
}
/**
@@ -190,6 +210,7 @@ export class CpqConfiguratorNormalizer
flatGroupList.push(group);
this.attachContainers(group, containers, currency, flatGroupList);
+ this.compileGroupComplete(group);
}
protected isUITypeReadOnly(attribute: Configurator.Attribute): boolean {
@@ -566,44 +587,155 @@ export class CpqConfiguratorNormalizer
return uiType;
}
+ /**
+ * Marks an attribute as incomplete when it has no selected value or user input.
+ *
+ * @param attribute - converted attribute
+ * @protected
+ */
protected compileAttributeIncomplete(attribute: Configurator.Attribute) {
//Default value for incomplete is false
attribute.incomplete = false;
- switch (attribute.uiType) {
- case Configurator.UiType.RADIOBUTTON:
- case Configurator.UiType.RADIOBUTTON_PRODUCT:
- case Configurator.UiType.DROPDOWN:
- case Configurator.UiType.DROPDOWN_PRODUCT:
- case Configurator.UiType.SINGLE_SELECTION_IMAGE: {
- if (
- !attribute.selectedSingleValue ||
- attribute.selectedSingleValue === Configurator.RetractValueCode
- ) {
- attribute.incomplete = true;
- }
- break;
+ const singleValueTypes = [
+ Configurator.UiType.RADIOBUTTON,
+ Configurator.UiType.RADIOBUTTON_PRODUCT,
+ Configurator.UiType.DROPDOWN,
+ Configurator.UiType.DROPDOWN_PRODUCT,
+ Configurator.UiType.SINGLE_SELECTION_IMAGE,
+ ];
+ const inputTypes = [
+ Configurator.UiType.NUMERIC,
+ Configurator.UiType.STRING,
+ ];
+ const multiValueTypes = [
+ Configurator.UiType.CHECKBOXLIST,
+ Configurator.UiType.CHECKBOXLIST_PRODUCT,
+ Configurator.UiType.CHECKBOX,
+ Configurator.UiType.MULTI_SELECTION_IMAGE,
+ ];
+ const uiType = attribute.uiType ?? Configurator.UiType.NOT_IMPLEMENTED;
+ if (singleValueTypes.includes(uiType)) {
+ this.compileAttributeIncompleteSingleLevel(attribute);
+ } else if (inputTypes.includes(uiType)) {
+ this.compileAttributeIncompleteInputTypes(attribute);
+ } else if (multiValueTypes.includes(uiType)) {
+ this.compileAttributeIncompleteMultiSelect(attribute);
+ } else if (uiType === Configurator.UiType.CONTAINER) {
+ this.compileAttributeIncompleteContainer(attribute);
+ }
+ }
+
+ /**
+ * Marks a single selection attribute as incomplete when it has no selected value or the retract value is selected.
+ *
+ * @param attribute - converted attribute
+ * @protected
+ */
+ protected compileAttributeIncompleteSingleLevel(
+ attribute: Configurator.Attribute
+ ): void {
+ if (
+ !attribute.selectedSingleValue ||
+ attribute.selectedSingleValue === Configurator.RetractValueCode
+ ) {
+ attribute.incomplete = true;
+ }
+ }
+
+ /**
+ * Marks an input type attribute as incomplete when it has no user input.
+ *
+ * @param attribute - converted attribute
+ * @protected
+ */
+ protected compileAttributeIncompleteInputTypes(
+ attribute: Configurator.Attribute
+ ): void {
+ if (!attribute.userInput) {
+ attribute.incomplete = true;
+ }
+ }
+
+ /**
+ * Marks a multi selection attribute as incomplete when it has no selected values.
+ *
+ * @param attribute - converted attribute
+ * @protected
+ */
+ protected compileAttributeIncompleteMultiSelect(
+ attribute: Configurator.Attribute
+ ): void {
+ attribute.incomplete = !attribute.values?.some((value) => value.selected);
+ }
+
+ /**
+ * Marks a container attribute as incomplete when the total number of selected
+ * rows is below the container `minRows`, or when the number of selected
+ * instances of a product is below that product row's `minRows`.
+ *
+ * @param attribute - converted attribute
+ * @protected
+ */
+ protected compileAttributeIncompleteContainer(
+ attribute: Configurator.Attribute
+ ): void {
+ const rows = attribute.container?.rows ?? [];
+ const totalSelectedRows = rows.filter((row) => row.selected).length;
+ const containerMinRows = attribute.container?.minRows ?? 0;
+
+ if (containerMinRows > 0 && totalSelectedRows < containerMinRows) {
+ attribute.incomplete = true;
+ return;
+ }
+
+ attribute.incomplete = this.hasContainerRowMinRowsNotMet(rows);
+ }
+
+ /**
+ * Returns whether any product row's `minRows` requirement is not met.
+ * Requirements are evaluated per product (`productSystemId`), falling back
+ * to the row `id` when no product system id is present.
+ *
+ * @param rows - container rows
+ * @returns `true` when at least one row `minRows` is not satisfied
+ * @protected
+ */
+ protected hasContainerRowMinRowsNotMet(
+ rows: Configurator.ContainerRow[]
+ ): boolean {
+ const requirements = new Map<
+ string,
+ {
+ minRows: number;
+ match: (row: Configurator.ContainerRow) => boolean;
}
- case Configurator.UiType.NUMERIC:
- case Configurator.UiType.STRING: {
- if (!attribute.userInput) {
- attribute.incomplete = true;
- }
- break;
+ >();
+
+ rows.forEach((row) => {
+ const minRows = row.minRows ?? 0;
+ if (minRows <= 0) {
+ return;
}
- case Configurator.UiType.CHECKBOXLIST:
- case Configurator.UiType.CHECKBOXLIST_PRODUCT:
- case Configurator.UiType.CHECKBOX:
- case Configurator.UiType.MULTI_SELECTION_IMAGE: {
- const isOneValueSelected =
- attribute.values?.find((value) => value.selected) !== undefined;
- if (!isOneValueSelected) {
- attribute.incomplete = true;
- }
- break;
+ const productKey = row.productSystemId ?? `id:${row.id}`;
+ const existing = requirements.get(productKey);
+ if (!existing || minRows > existing.minRows) {
+ requirements.set(productKey, {
+ minRows,
+ match: row.productSystemId
+ ? (entry) => entry.productSystemId === row.productSystemId
+ : (entry) => entry.id === row.id,
+ });
}
- }
+ });
+
+ return Array.from(requirements.values()).some(({ minRows, match }) => {
+ const selectedCount = rows.filter(
+ (row) => row.selected && match(row)
+ ).length;
+ return selectedCount < minRows;
+ });
}
protected hasValueToBeIgnored(
@@ -623,6 +755,61 @@ export class CpqConfiguratorNormalizer
);
}
+ /**
+ * Sets the group's completeness to `false` when any attribute in the group
+ * or any subgroup is incomplete, propagates incompleteness down to a single
+ * subgroup, and propagates incompleteness to all ancestor groups.
+ *
+ * @param group - converted group
+ * @param ancestorGroups - parent groups up to the root
+ * @protected
+ */
+ protected compileGroupComplete(
+ group: Configurator.Group,
+ ancestorGroups: Configurator.Group[] = []
+ ): void {
+ if (group.attributes?.some((attribute) => attribute.incomplete)) {
+ group.complete = false;
+ }
+ if (group.subGroups.some((subGroup) => subGroup.complete === false)) {
+ group.complete = false;
+ }
+ if (group.complete === false) {
+ this.propagateGroupIncompletenessToSingleSubGroup(group);
+ this.propagateGroupIncompletenessToAncestors(ancestorGroups);
+ }
+ }
+
+ /**
+ * Propagates incompleteness to the only subgroup when the parent group has
+ * exactly one child. This is required for nested container-row groups that
+ * are condensed with their single tab in the group menu.
+ *
+ * @param group - converted group
+ * @protected
+ */
+ protected propagateGroupIncompletenessToSingleSubGroup(
+ group: Configurator.Group
+ ): void {
+ if (group.subGroups.length === 1) {
+ group.subGroups[0].complete = false;
+ }
+ }
+
+ /**
+ * Marks all ancestor groups as incomplete.
+ *
+ * @param ancestorGroups - parent groups up to the root
+ * @protected
+ */
+ protected propagateGroupIncompletenessToAncestors(
+ ancestorGroups: Configurator.Group[]
+ ): void {
+ ancestorGroups.forEach((ancestor) => {
+ ancestor.complete = false;
+ });
+ }
+
/**
* Attaches matching CPQ containers to the group's attributes and appends
* nested container-row groups to the group's subGroups.
@@ -648,10 +835,35 @@ export class CpqConfiguratorNormalizer
currency,
flatGroupList
);
+ this.applyContainerRequired(attribute);
+ this.compileAttributeIncomplete(attribute);
}
});
}
+ /**
+ * Marks a container attribute as required when the container or any of its
+ * rows has `minRows` of at least 1, even if the source CPQ attribute is not
+ * required. CPQ signals a container as non-complete in case nothing is
+ * selected even if it's marked as non-required attribute in CPQ modeling.
+ *
+ * @param attribute - converted attribute
+ */
+ protected applyContainerRequired(attribute: Configurator.Attribute): void {
+ if (attribute.uiType !== Configurator.UiType.CONTAINER) {
+ return;
+ }
+
+ const hasContainerMinRows = (attribute.container?.minRows ?? 0) >= 1;
+ const hasRowMinRows = attribute.container?.rows?.some(
+ (row) => (row.minRows ?? 0) >= 1
+ );
+
+ if (hasContainerMinRows || hasRowMinRows) {
+ attribute.required = true;
+ }
+ }
+
protected convertContainer(
source: Cpq.Container,
attrCode: number,
@@ -662,7 +874,7 @@ export class CpqConfiguratorNormalizer
return {
minRows: source.minRows,
maxRows: source.maxRows,
- failedValidations: source.failedValidations,
+ messages: this.convertMessages(source.messages),
rows: (source.rows ?? []).map((row) =>
this.convertContainerRow(
row,
@@ -682,21 +894,25 @@ export class CpqConfiguratorNormalizer
currency: string,
flatGroupList: Configurator.Group[]
): Configurator.ContainerRow {
+ const nestedConfiguration = source.configuration;
const row: Configurator.ContainerRow = {
id: source.id,
+ minRows: source.minRows,
+ maxRows: source.maxRows,
productSystemId: source.productSystemId,
productName: source.productName,
selected: source.selected,
actions: this.convertContainerRowActions(source.actions),
};
- if (source.configuration) {
+ if (nestedConfiguration) {
const rowGroup = this.convertNestedConfiguration(
- source.configuration,
+ nestedConfiguration,
source,
attrCode,
currency,
- flatGroupList
+ flatGroupList,
+ parentGroup
);
parentGroup.subGroups.push(rowGroup);
row.groupId = rowGroup.id;
@@ -710,7 +926,8 @@ export class CpqConfiguratorNormalizer
row: Cpq.ContainerRow,
attrCode: number,
currency: string,
- flatGroupList: Configurator.Group[]
+ flatGroupList: Configurator.Group[],
+ parentGroup: Configurator.Group
): Configurator.Group {
const rowGroup: Configurator.Group = {
id: `${Configurator.ContainerRowGroupIdPrefix}@${attrCode}@${row.id}`,
@@ -725,6 +942,7 @@ export class CpqConfiguratorNormalizer
messages: this.convertMessages(source.messages),
};
+ const ancestors = [parentGroup];
source.tabs?.forEach((tab) =>
this.convertGroup(
tab,
@@ -734,10 +952,14 @@ export class CpqConfiguratorNormalizer
flatGroupList,
source.containers,
row.id,
- rowGroup.id
+ rowGroup.id,
+ rowGroup,
+ ancestors
)
);
+ this.compileGroupComplete(rowGroup, ancestors);
+
return rowGroup;
}
@@ -755,16 +977,28 @@ export class CpqConfiguratorNormalizer
}));
}
+ /**
+ * Converts CPQ message severity to the configurator-independent model.
+ *
+ * Note: This is NOT a one-to-one mapping. CPQ severity levels are intentionally
+ * escalated to be more strict in the configurator model:
+ * - CPQ INFO is mapped to CONFIGURATOR WARNING (informational messages become warnings)
+ * - CPQ WARNING is mapped to CONFIGURATOR ERROR (warnings become errors)
+ * This escalation ensures that important information from CPQ is more prominently
+ * surfaced to users in the configurator UI.
+ *
+ * @param severity - CPQ message severity
+ * @returns Escalated Configurator message severity, or `undefined` if the CPQ severity is not recognized
+ * @protected
+ */
protected convertMessageSeverity(
- severity?: string
+ severity?: Cpq.MessageSeverity
): Configurator.MessageSeverity | undefined {
switch (severity) {
case Cpq.MessageSeverity.INFO:
- case Configurator.MessageSeverity.INFO:
- return Configurator.MessageSeverity.INFO;
- case Cpq.MessageSeverity.WARNING:
- case Configurator.MessageSeverity.WARNING:
return Configurator.MessageSeverity.WARNING;
+ case Cpq.MessageSeverity.WARNING:
+ return Configurator.MessageSeverity.ERROR;
default:
return undefined;
}
diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-overview-normalizer.spec.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-overview-normalizer.spec.ts
index fc94105534e..bc41ea59584 100644
--- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-overview-normalizer.spec.ts
+++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-overview-normalizer.spec.ts
@@ -201,6 +201,77 @@ describe('CpqConfiguratorOverviewNormalizer', () => {
).toBe(0);
});
+ it('should include nested container-row issues in totalNumberOfIssues', () => {
+ const configurationWithNestedIssues: Cpq.Configuration = {
+ ...completeAndConsistentInput,
+ sapContainers: [
+ {
+ stdAttrCode: 11,
+ rows: [
+ {
+ id: '018',
+ productSystemId: 'LENS_ZOOM',
+ configuration: {
+ completed: false,
+ errorMessages: [ERROR_MSG],
+ invalidMessages: [INVALID_MSG],
+ failedValidations: [VALIDATION_MSG],
+ incompleteMessages: [INCOMPLETE_MSG],
+ messages: [
+ {
+ message: 'Check zoom range',
+ severity: Cpq.MessageSeverity.WARNING,
+ },
+ {
+ message: 'Info only',
+ severity: Cpq.MessageSeverity.INFO,
+ },
+ ],
+ },
+ },
+ ],
+ },
+ ],
+ };
+ expect(
+ serviceUnderTest.convert(configurationWithNestedIssues)
+ .totalNumberOfIssues
+ ).toBe(6);
+ });
+
+ it('should include incomplete nested tab attributes in totalNumberOfIssues', () => {
+ const configurationWithNestedIssues: Cpq.Configuration = {
+ ...completeAndConsistentInput,
+ sapContainers: [
+ {
+ stdAttrCode: 11,
+ rows: [
+ {
+ id: '018',
+ productSystemId: 'LENS_ZOOM',
+ configuration: {
+ completed: false,
+ tabs: [
+ {
+ id: 1,
+ attributes: [
+ { pA_ID: 1, stdAttrCode: 11, incomplete: true },
+ { pA_ID: 2, stdAttrCode: 12, incomplete: true },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ },
+ ],
+ };
+ expect(
+ serviceUnderTest.convert(configurationWithNestedIssues)
+ .totalNumberOfIssues
+ ).toBe(2);
+ });
+
it('should prepare price summary', () => {
const convertedPriceSummary = serviceUnderTest.convert(input).priceSummary;
expect(convertedPriceSummary?.currentTotal?.formattedValue).toBe(
diff --git a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-overview-normalizer.ts b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-overview-normalizer.ts
index 926fb1b56e6..7a86ac26d2b 100644
--- a/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-overview-normalizer.ts
+++ b/feature-libs/product-configurator/rulebased/cpq/common/converters/cpq-configurator-overview-normalizer.ts
@@ -186,12 +186,8 @@ export class CpqConfiguratorOverviewNormalizer
}
protected calculateTotalNumberOfIssues(source: Cpq.Configuration): number {
- const numberOfIssues: number =
- (source.incompleteAttributes?.length ?? 0) +
- (source.incompleteMessages?.length ?? 0) +
- (source.invalidMessages?.length ?? 0) +
- (source.failedValidations?.length ?? 0) +
- (source.errorMessages?.length ?? 0);
- return numberOfIssues;
+ return this.cpqConfiguratorNormalizerUtilsService.calculateTotalNumberOfIssues(
+ source
+ );
}
}
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 995276de63c..9a14f6d1abd 100644
--- a/feature-libs/product-configurator/rulebased/cpq/common/cpq.models.ts
+++ b/feature-libs/product-configurator/rulebased/cpq/common/cpq.models.ts
@@ -51,7 +51,7 @@ export namespace Cpq {
stdAttrCode: number;
minRows?: number;
maxRows?: number;
- failedValidations?: string[];
+ messages?: Message[];
rows?: ContainerRow[];
}
@@ -60,6 +60,8 @@ export namespace Cpq {
*/
export interface ContainerRow {
id: string;
+ minRows?: number;
+ maxRows?: number;
productSystemId?: string;
productName?: string;
selected?: boolean;
@@ -72,6 +74,10 @@ export namespace Cpq {
*/
export interface NestedProductConfiguration {
completed?: boolean;
+ incompleteMessages?: string[];
+ invalidMessages?: string[];
+ failedValidations?: string[];
+ errorMessages?: string[];
messages?: Message[];
tabs?: Tab[];
containers?: Container[];
diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-product-card.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-product-card.scss
index 14e61335aad..b021051830d 100644
--- a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-product-card.scss
+++ b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-product-card.scss
@@ -1,16 +1,19 @@
%cx-configurator-attribute-product-card {
&:first-of-type {
- .cx-product-card {
+ .cx-product-card-container {
border-top: solid 1px var(--cx-color-light);
}
}
+ .cx-product-card-container {
+ border-bottom: solid 1px var(--cx-color-light);
+ }
+
.cx-product-card {
padding-block-start: 16px;
padding-block-end: 16px;
width: 100%;
min-height: 140px;
- border-bottom: solid 1px var(--cx-color-light);
@include media-breakpoint-down(sm) {
padding-inline-start: 16px;
@@ -177,7 +180,15 @@
&.deselection-error-message {
display: inline-block;
- width: 80%;
+ width: 95%;
+ }
+
+ &.cx-deselection-error-msg,
+ &.cx-container-error-msg,
+ &.cx-container-warning-msg,
+ &.cx-container-info-msg {
+ display: inline-block;
+ width: 100%;
}
}
@@ -248,8 +259,18 @@
}
}
}
+
+ &.message {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ padding-inline-start: 16px;
+ padding-inline-end: 16px;
+ padding-block-end: 16px;
+ }
}
+ .cx-product-card-container-selected,
.cx-product-card-selected {
flex-wrap: wrap;
background-color: var(--cx-color-background);
@@ -264,6 +285,13 @@
color: var(--cx-color-danger);
}
+ .cx-deselection-error-msg {
+ font-size: 14px;
+ line-height: 1.2;
+ padding-top: 5px;
+ color: var(--cx-color-danger);
+ }
+
.deselection-error-symbol {
padding: 5px;
}
diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-message.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-message.scss
new file mode 100644
index 00000000000..25d2889e4f3
--- /dev/null
+++ b/feature-libs/product-configurator/rulebased/styles/_configurator-message.scss
@@ -0,0 +1,21 @@
+%cx-configurator-message {
+ display: contents;
+
+ // Applies the error message styling to any class ending with `-error-msg`
+ // e.g. .cx-error-msg, .cx-container-error-msg, .cx-attribute-error-msg
+ [class*='-error-msg'] {
+ @include cx-configurator-error-msg();
+ }
+
+ // Applies the warning message styling to any class ending with `-warning-msg`
+ // e.g. .cx-warning-msg, .cx-container-warning-msg, .cx-attribute-warning-msg
+ [class*='-warning-msg'] {
+ @include cx-configurator-warning-msg();
+ }
+
+ // Applies the info message styling to any class ending with `-info-msg`
+ // e.g. .cx-info-msg, .cx-container-info-msg, .cx-attribute-info-msg
+ [class*='-info-msg'] {
+ @include cx-configurator-info-msg();
+ }
+}
diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-show-more.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-show-more.scss
index dd4235f3f11..5da6c2705a3 100644
--- a/feature-libs/product-configurator/rulebased/styles/_configurator-show-more.scss
+++ b/feature-libs/product-configurator/rulebased/styles/_configurator-show-more.scss
@@ -1,8 +1,6 @@
%cx-configurator-show-more {
&:not(:empty) {
- font-size: 14px;
- line-height: 1.2;
- padding-block-end: 10px;
+ @include cx-configurator-msg();
button {
background-color: transparent;
diff --git a/feature-libs/product-configurator/rulebased/styles/_index.scss b/feature-libs/product-configurator/rulebased/styles/_index.scss
index cc78a7dc796..42125ef046a 100644
--- a/feature-libs/product-configurator/rulebased/styles/_index.scss
+++ b/feature-libs/product-configurator/rulebased/styles/_index.scss
@@ -42,6 +42,7 @@
@import 'configurator-product-title';
@import 'configurator-restart-dialog';
@import 'configurator-show-more';
+@import 'configurator-message';
@import 'configurator-tab-bar';
@import 'configurator-update-message';
@import 'configurator-conflict-and-error-messages';
diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-error-msg.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-error-msg.scss
index 81b13fc85eb..871db67a1b0 100644
--- a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-error-msg.scss
+++ b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-error-msg.scss
@@ -1,8 +1,3 @@
@mixin cx-configurator-error-msg {
- color: var(--cx-color-danger);
- font-size: 14px;
-
- cx-icon {
- padding-inline-end: 5px;
- }
+ @include cx-configurator-severity-msg(var(--cx-color-danger));
}
diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-info-msg.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-info-msg.scss
new file mode 100644
index 00000000000..b407631310c
--- /dev/null
+++ b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-info-msg.scss
@@ -0,0 +1,3 @@
+@mixin cx-configurator-info-msg() {
+ @include cx-configurator-severity-msg(var(--cx-color-text));
+}
diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-msg.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-msg.scss
new file mode 100644
index 00000000000..4c6b75cdf2d
--- /dev/null
+++ b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-msg.scss
@@ -0,0 +1,5 @@
+@mixin cx-configurator-msg {
+ font-size: 14px;
+ line-height: 1.2;
+ padding-block-end: 10px;
+}
diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-severity-msg.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-severity-msg.scss
new file mode 100644
index 00000000000..0dc5f82924b
--- /dev/null
+++ b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-severity-msg.scss
@@ -0,0 +1,8 @@
+@mixin cx-configurator-severity-msg($color) {
+ color: $color;
+ font-size: 14px;
+
+ cx-icon {
+ padding-inline-end: 5px;
+ }
+}
diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-warning-msg.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-warning-msg.scss
new file mode 100644
index 00000000000..a7296f195e6
--- /dev/null
+++ b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-warning-msg.scss
@@ -0,0 +1,3 @@
+@mixin cx-configurator-warning-msg {
+ @include cx-configurator-severity-msg(var(--cx-color-warning));
+}
diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_mixins.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_mixins.scss
index a2915636db6..54570ec70cd 100644
--- a/feature-libs/product-configurator/rulebased/styles/mixins/_mixins.scss
+++ b/feature-libs/product-configurator/rulebased/styles/mixins/_mixins.scss
@@ -5,7 +5,11 @@
@import 'configurator-attribute-visible-focus';
@import 'configurator-form-group';
@import 'configurator-group-attribute';
+@import 'configurator-msg';
+@import 'configurator-severity-msg';
@import 'configurator-error-msg';
+@import 'configurator-warning-msg';
+@import 'configurator-info-msg';
@import 'configurator-required-error-msg';
@import 'configurator-validation-msg';
@import 'configurator-attribute-selection-image';