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) }} + - + + +
+
+
+ + + +
+ + + +
+
+
+
+
+ +
+
+

+ {{ product.name }} +

+
+
+ {{ 'configurator.attribute.id' | cxTranslate }}: + {{ product.code }} +
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+ +
- - +
  • + +
  • + +
    - + + - - + + + + + + + + + + + + + - - - - {{ getAriaLabelSingleSelectedNoButton(product) }} - - - - - + + + + + {{ getAriaLabelSingleSelectedNoButton(product) }} + + + + + +
    +
    + +
    + + + + +
    +
    - - - -
    + 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 @@ - + +
    {{ 'configurator.header.multipleWarnings' | cxTranslate }}
    + {{ warningMessage }} +
    +
    {{ warningMessage }}
    - +
    - + {{ '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';