From 1d796e2db93b8c4c50a1fc869c88d166072b99d1 Mon Sep 17 00:00:00 2001 From: Mateusz Kolasa Date: Thu, 14 Jan 2021 15:03:28 +0100 Subject: [PATCH 01/30] fix: add redirect after password change for b2b user --- .../user-change-password-form.component.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/feature-libs/organization/administration/components/user/change-password-form/user-change-password-form.component.ts b/feature-libs/organization/administration/components/user/change-password-form/user-change-password-form.component.ts index d5020874e5b..83ac50c89c8 100644 --- a/feature-libs/organization/administration/components/user/change-password-form/user-change-password-form.component.ts +++ b/feature-libs/organization/administration/components/user/change-password-form/user-change-password-form.component.ts @@ -40,7 +40,10 @@ export class UserChangePasswordFormComponent { ) ) ) - .subscribe((data) => this.notify(data)); + .subscribe((data) => { + this.notify(data); + this.itemService.launchDetails(data); + }); } protected notify(item: User) { From a821f418c7c77e6beaae28160b2bea348ecb7dd1 Mon Sep 17 00:00:00 2001 From: tobi-or-not-tobi Date: Fri, 15 Jan 2021 15:50:10 +0100 Subject: [PATCH 02/30] fix: absolute media url starting with double slash (#10715) When media URLs start with a double slash (to accommodate usage cross http and https), these are considered absolute URLs. fixes #10711 --- .../src/shared/components/media/media.service.spec.ts | 9 +++++++++ .../src/shared/components/media/media.service.ts | 10 ++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/projects/storefrontlib/src/shared/components/media/media.service.spec.ts b/projects/storefrontlib/src/shared/components/media/media.service.spec.ts index fca98fee6ad..357d1509b15 100644 --- a/projects/storefrontlib/src/shared/components/media/media.service.spec.ts +++ b/projects/storefrontlib/src/shared/components/media/media.service.spec.ts @@ -72,6 +72,9 @@ const mockUrlContainer = { absolute: { url: 'http://absolute.jpg', }, + doubleSlash: { + url: '//absolute.jpg', + }, relative: { url: 'relative.jpg', }, @@ -237,6 +240,12 @@ describe('MediaService', () => { ); }); + it('should threat image url start with double slash as absolute URL', () => { + expect(mediaService.getMedia(mockUrlContainer, 'doubleSlash').src).toBe( + '//absolute.jpg' + ); + }); + it('should add OCC baseUrl for relative image', () => { expect(mediaService.getMedia(mockUrlContainer, 'relative').src).toBe( 'base:relative.jpg' diff --git a/projects/storefrontlib/src/shared/components/media/media.service.ts b/projects/storefrontlib/src/shared/components/media/media.service.ts index 58e464b0f2c..5241cebe7f2 100644 --- a/projects/storefrontlib/src/shared/components/media/media.service.ts +++ b/projects/storefrontlib/src/shared/components/media/media.service.ts @@ -183,12 +183,14 @@ export class MediaService { /** * Resolves the absolute URL for the given url. In most cases, this URL represents * the relative URL on the backend. In that case, we prefix the url with the baseUrl. + * + * When we have receive an absolute URL, we return the URL as-is. An absolute URL might also + * start with double slash, which is used to resolve media cross from http and https. */ protected resolveAbsoluteUrl(url: string): string { - if (!url) { - return null; - } - return url.startsWith('http') ? url : this.getBaseUrl() + url; + return !url || url.startsWith('http') || url.startsWith('//') + ? url + : this.getBaseUrl() + url; } /** From 35307f1c5a232420b5cfcb1e7357d4d717793c45 Mon Sep 17 00:00:00 2001 From: Louis Pierrestiger Date: Fri, 15 Jan 2021 11:24:53 -0500 Subject: [PATCH 03/30] feat: split occ endpoints configuration to multiple libraries (#10594) Configurations for OCC endpoints now live in their respective feature libraries. To mitigate breaking changes the configurations are still in core until 4.0. Closes #9112 --- .../administration/occ/model/index.ts | 2 + .../occ-administration-endpoints.model.ts | 234 ++++++++++++++++++ .../administration/occ/public_api.ts | 1 + .../organization/order-approval/occ/index.ts | 1 + .../order-approval/occ/model/index.ts | 2 + .../occ-order-approval-endpoints.model.ts | 24 ++ feature-libs/storefinder/occ/model/index.ts | 2 + .../model/occ-storefinder-endpoints.model.ts | 24 ++ feature-libs/storefinder/occ/public_api.ts | 1 + projects/core/public_api.ts | 1 + .../core/src/config/utils/dynamic-template.ts | 18 +- .../occ/adapters/asm/occ-asm.adapter.spec.ts | 34 ++- .../src/occ/adapters/asm/occ-asm.adapter.ts | 6 +- .../occ-checkout-cost-center.adapter.spec.ts | 2 +- .../occ-checkout-payment-type.adapter.spec.ts | 2 +- .../site-context/occ-site.adapter.spec.ts | 24 +- .../adapters/site-context/occ-site.adapter.ts | 4 +- .../config-loader/occ-sites-config-loader.ts | 14 +- projects/core/src/occ/index.ts | 3 +- .../src/occ/occ-models/occ-endpoints.model.ts | 119 ++++----- .../services/occ-endpoints.service.spec.ts | 156 ++++++++++++ .../src/occ/services/occ-endpoints.service.ts | 184 ++++++++++++-- projects/core/src/occ/utils/index.ts | 3 + .../core/src/occ/utils/occ-url-util.spec.ts | 31 +++ projects/core/src/occ/utils/occ-url-util.ts | 27 ++ 25 files changed, 805 insertions(+), 114 deletions(-) create mode 100644 feature-libs/organization/administration/occ/model/index.ts create mode 100644 feature-libs/organization/administration/occ/model/occ-administration-endpoints.model.ts create mode 100644 feature-libs/organization/order-approval/occ/model/index.ts create mode 100644 feature-libs/organization/order-approval/occ/model/occ-order-approval-endpoints.model.ts create mode 100644 feature-libs/storefinder/occ/model/index.ts create mode 100644 feature-libs/storefinder/occ/model/occ-storefinder-endpoints.model.ts create mode 100644 projects/core/src/occ/utils/index.ts create mode 100644 projects/core/src/occ/utils/occ-url-util.spec.ts create mode 100644 projects/core/src/occ/utils/occ-url-util.ts diff --git a/feature-libs/organization/administration/occ/model/index.ts b/feature-libs/organization/administration/occ/model/index.ts new file mode 100644 index 00000000000..0b3425a3074 --- /dev/null +++ b/feature-libs/organization/administration/occ/model/index.ts @@ -0,0 +1,2 @@ +// Imported for side effects (module augmentation) +import './occ-administration-endpoints.model'; diff --git a/feature-libs/organization/administration/occ/model/occ-administration-endpoints.model.ts b/feature-libs/organization/administration/occ/model/occ-administration-endpoints.model.ts new file mode 100644 index 00000000000..3ec2826a922 --- /dev/null +++ b/feature-libs/organization/administration/occ/model/occ-administration-endpoints.model.ts @@ -0,0 +1,234 @@ +import { OccEndpoint } from '@spartacus/core'; + +declare module '@spartacus/core' { + interface OccEndpoints { + /** + * Endpoint for organization customers + * + * @member {string} + */ + b2bUsers?: string | OccEndpoint; + /** + * Endpoint for organization customer + * + * @member {string} + */ + b2bUser?: string | OccEndpoint; + /** + * Endpoint for organization customer approvers + * + * @member {string} + */ + b2bUserApprovers?: string | OccEndpoint; + /** + * Endpoint for organization customer approver + * + * @member {string} + */ + b2bUserApprover?: string | OccEndpoint; + /** + * Endpoint for organization customer user groups + * + * @member {string} + */ + b2bUserUserGroups?: string | OccEndpoint; + /** + * Endpoint for organization customer user group + * + * @member {string} + */ + b2bUserUserGroup?: string | OccEndpoint; + /** + * Endpoint for organization customer permissions + * + * @member {string} + */ + b2bUserPermissions?: string | OccEndpoint; + /** + * Endpoint for organization customer permission + * + * @member {string} + */ + b2bUserPermission?: string | OccEndpoint; + /** + * Endpoint for userGroupOrderApprovalPermission + * + * @member {string} + */ + budget?: string | OccEndpoint; + /** + * Endpoint for budgets list + * + * @member {string} + */ + budgets?: string | OccEndpoint; + /** + * Endpoint for all costCenters + * + * @member {string} + */ + costCentersAll?: string | OccEndpoint; + /** + * Endpoint for costCenter + * + * @member {string} + */ + costCenter?: string | OccEndpoint; + /** + * Endpoint for userGroupOrderApprovalPermission + * + * @member {string} + */ + costCenters?: string | OccEndpoint; + /** + * Endpoint for budgets assigned to costCenter + * + * @member {string} + */ + costCenterBudgets?: string | OccEndpoint; + /** + * Endpoint for budget assigned to costCenter + * + * @member {string} + */ + costCenterBudget?: string | OccEndpoint; + /** + * Endpoint for organizations + * + * @member {string} + */ + orgUnits?: string | OccEndpoint; + /** + * Endpoint for organizations list + * + * @member {string} + */ + orgUnitsAvailable?: string | OccEndpoint; + /** + * Endpoint for organization units tree + * + * @member {string} + */ + orgUnitsTree?: string | OccEndpoint; + /** + * Endpoint for approval processes for organization units + * + * @member {string} + */ + orgUnitsApprovalProcesses?: string | OccEndpoint; + /** + * Endpoint for organization + * + * @member {string} + */ + orgUnit?: string | OccEndpoint; + /** + * Endpoint for orgUnitUsers: + * + * @member {string} + */ + orgUnitUsers?: string | OccEndpoint; + /** + * Endpoint for add orgUnitUserRoles (except approver): + * + * @member {string} + */ + orgUnitUserRoles?: string | OccEndpoint; + /** + * Endpoint for remove orgUnitUserRole (except approver): + * + * @member {string} + */ + orgUnitUserRole?: string | OccEndpoint; + /** + * Endpoint for add orgUnitApprovers: + * + * @member {string} + */ + orgUnitApprovers?: string | OccEndpoint; + /** + * Endpoint for delete orgUnitApprover: + * + * @member {string} + */ + orgUnitApprover?: string | OccEndpoint; + /** + * Endpoint for organizational unit addresses + * + * @member {string} + */ + orgUnitsAddresses?: string | OccEndpoint; + /** + * Endpoint for organizational unit address + * + * @member {string} + */ + orgUnitsAddress?: string | OccEndpoint; + /** + * Endpoint for permission list + * + * @member {string} + */ + permissions?: string | OccEndpoint; + /** + * Endpoint for permission + * + * @member {string} + */ + permission?: string | OccEndpoint; + /** + * Endpoint for order approval permission types + * + * @member {string} + */ + orderApprovalPermissionTypes?: string | OccEndpoint; + /** + * Endpoint for organizational unit user groups list + * + * @member {string} + */ + userGroups?: string | OccEndpoint; + /** + * Endpoint for organizational unit user group + * + * @member {string} + */ + userGroup?: string | OccEndpoint; + /** + * Endpoint for costCenter list + * + * @member {string} + */ + userGroupAvailableOrderApprovalPermissions?: string | OccEndpoint; + /** + * Endpoint for userGroupAvailableOrderApprovalPermissions list + * + * @member {string} + */ + userGroupAvailableOrgCustomers?: string | OccEndpoint; + /** + * Endpoint for userGroupAvailableOrgCustomers list + * + * @member {string} + */ + userGroupMembers?: string | OccEndpoint; + /** + * Endpoint for userGroupMembers list + * + * @member {string} + */ + userGroupMember?: string | OccEndpoint; + /** + * Endpoint for userGroupMember + * + * @member {string} + */ + userGroupOrderApprovalPermissions?: string | OccEndpoint; + /** + * Endpoint for userGroupOrderApprovalPermissions list + * + * @member {string} + */ + userGroupOrderApprovalPermission?: string | OccEndpoint; + } +} diff --git a/feature-libs/organization/administration/occ/public_api.ts b/feature-libs/organization/administration/occ/public_api.ts index 3300bf952f3..97ab73e95b3 100644 --- a/feature-libs/organization/administration/occ/public_api.ts +++ b/feature-libs/organization/administration/occ/public_api.ts @@ -1,3 +1,4 @@ export * from './adapters/index'; export * from './administration-occ.module'; export * from './converters/index'; +export * from './model/index'; diff --git a/feature-libs/organization/order-approval/occ/index.ts b/feature-libs/organization/order-approval/occ/index.ts index 4e6bd3c367c..a9229c23b8d 100644 --- a/feature-libs/organization/order-approval/occ/index.ts +++ b/feature-libs/organization/order-approval/occ/index.ts @@ -1,3 +1,4 @@ export * from './adapters/index'; export * from './converters/index'; +export * from './model/index'; export * from './order-approval-occ.module'; diff --git a/feature-libs/organization/order-approval/occ/model/index.ts b/feature-libs/organization/order-approval/occ/model/index.ts new file mode 100644 index 00000000000..6f2957f03c0 --- /dev/null +++ b/feature-libs/organization/order-approval/occ/model/index.ts @@ -0,0 +1,2 @@ +// Imported for side effects (module augmentation) +import './occ-order-approval-endpoints.model'; diff --git a/feature-libs/organization/order-approval/occ/model/occ-order-approval-endpoints.model.ts b/feature-libs/organization/order-approval/occ/model/occ-order-approval-endpoints.model.ts new file mode 100644 index 00000000000..ee337b1ac2c --- /dev/null +++ b/feature-libs/organization/order-approval/occ/model/occ-order-approval-endpoints.model.ts @@ -0,0 +1,24 @@ +import { OccEndpoint } from '@spartacus/core'; + +declare module '@spartacus/core' { + interface OccEndpoints { + /** + * Endpoint for order approvals + * + * @member {string} + */ + orderApprovals?: string | OccEndpoint; + /** + * Endpoint for order approval + * + * @member {string} + */ + orderApproval?: string | OccEndpoint; + /** + * Endpoint for order approval decision + * + * @member {string} + */ + orderApprovalDecision?: string | OccEndpoint; + } +} diff --git a/feature-libs/storefinder/occ/model/index.ts b/feature-libs/storefinder/occ/model/index.ts new file mode 100644 index 00000000000..7a9a8be0186 --- /dev/null +++ b/feature-libs/storefinder/occ/model/index.ts @@ -0,0 +1,2 @@ +// Imported for side effects (module augmentation) +import './occ-storefinder-endpoints.model'; diff --git a/feature-libs/storefinder/occ/model/occ-storefinder-endpoints.model.ts b/feature-libs/storefinder/occ/model/occ-storefinder-endpoints.model.ts new file mode 100644 index 00000000000..aa4ba5cc736 --- /dev/null +++ b/feature-libs/storefinder/occ/model/occ-storefinder-endpoints.model.ts @@ -0,0 +1,24 @@ +import { OccEndpoint } from '@spartacus/core'; + +declare module '@spartacus/core' { + interface OccEndpoints { + /** + * Get a store location + * + * @member {string} [page] + */ + store?: string | OccEndpoint; + /** + * Get a list of store locations + * + * @member {string} [page] + */ + stores?: string | OccEndpoint; + /** + * Gets a store location count per country and regions + * + * @member {string} [page] + */ + storescounts?: string | OccEndpoint; + } +} diff --git a/feature-libs/storefinder/occ/public_api.ts b/feature-libs/storefinder/occ/public_api.ts index 2b018fb6344..75b88e4db92 100644 --- a/feature-libs/storefinder/occ/public_api.ts +++ b/feature-libs/storefinder/occ/public_api.ts @@ -1,2 +1,3 @@ export * from './adapters/index'; +export * from './model/index'; export * from './store-finder-occ.module'; diff --git a/projects/core/public_api.ts b/projects/core/public_api.ts index 1890c696dd7..a4be72dd872 100644 --- a/projects/core/public_api.ts +++ b/projects/core/public_api.ts @@ -34,4 +34,5 @@ export { Cart } from './src/model/cart.model'; export { CostCenter, B2BUnit, B2BUser } from './src/model/org-unit.model'; export { AuthToken } from './src/auth/user-auth/models/auth-token.model'; export { Order, OrderEntry, DeliveryMode } from './src/model/order.model'; +export { OccEndpoints } from './src/occ/occ-models/occ-endpoints.model'; /** AUGMENTABLE_TYPES_END */ diff --git a/projects/core/src/config/utils/dynamic-template.ts b/projects/core/src/config/utils/dynamic-template.ts index 509813f5db2..366e10137c4 100644 --- a/projects/core/src/config/utils/dynamic-template.ts +++ b/projects/core/src/config/utils/dynamic-template.ts @@ -1,10 +1,24 @@ export class DynamicTemplate { - static resolve(templateString: string, templateVariables: Object) { + /** + * Populates the given template with the variables provided + * + * @param templateString template of the OCC endpoint + * @param templateVariables variables to replace in the template + * @param encodeVariable encode variable before placing it in the template + */ + static resolve( + templateString: string, + templateVariables: Object, + encodeVariable?: boolean + ): string { for (const variableLabel of Object.keys(templateVariables)) { const placeholder = new RegExp('\\${' + variableLabel + '}', 'g'); templateString = templateString.replace( placeholder, - templateVariables[variableLabel] + // TODO 4.0: default to encodeVariable = true + encodeVariable + ? encodeURIComponent(templateVariables[variableLabel]) + : templateVariables[variableLabel] ); } return templateString; diff --git a/projects/core/src/occ/adapters/asm/occ-asm.adapter.spec.ts b/projects/core/src/occ/adapters/asm/occ-asm.adapter.spec.ts index 15eed232b19..ab090cd12c2 100644 --- a/projects/core/src/occ/adapters/asm/occ-asm.adapter.spec.ts +++ b/projects/core/src/occ/adapters/asm/occ-asm.adapter.spec.ts @@ -14,7 +14,11 @@ import { import { User } from '../../../model/misc.model'; import { BaseSiteService } from '../../../site-context/facade/base-site.service'; import { ConverterService } from '../../../util/converter.service'; -import { OccEndpointsService } from '../../services'; +import { + BaseOccUrlProperties, + DynamicAttributes, + OccEndpointsService, +} from '../../services'; import { OccAsmAdapter } from './occ-asm.adapter'; const MockAsmConfig: AsmConfig = { @@ -46,8 +50,12 @@ class MockBaseSiteService { } } -class MockOccEndpointsService { - getRawEndpoint(endpoint: string): string { +class MockOccEndpointsService implements Partial { + buildUrl( + endpoint: string, + _attributes?: DynamicAttributes, + _propertiesToOmit?: BaseOccUrlProperties + ) { return endpoint; } } @@ -74,7 +82,7 @@ describe('OccAsmAdapter', () => { converterService = TestBed.inject(ConverterService); occEnpointsService = TestBed.inject(OccEndpointsService); spyOn(converterService, 'pipeable').and.callThrough(); - spyOn(occEnpointsService, 'getRawEndpoint').and.callThrough(); + spyOn(occEnpointsService, 'buildUrl').and.callThrough(); }); it('should be created', () => { @@ -108,8 +116,10 @@ describe('OccAsmAdapter', () => { expect(converterService.pipeable).toHaveBeenCalledWith( CUSTOMER_SEARCH_PAGE_NORMALIZER ); - expect(occEnpointsService.getRawEndpoint).toHaveBeenCalledWith( - 'asmCustomerSearch' + expect(occEnpointsService.buildUrl).toHaveBeenCalledWith( + 'asmCustomerSearch', + {}, + { prefix: false, baseSite: false } ); }); @@ -135,8 +145,10 @@ describe('OccAsmAdapter', () => { expect(converterService.pipeable).toHaveBeenCalledWith( CUSTOMER_SEARCH_PAGE_NORMALIZER ); - expect(occEnpointsService.getRawEndpoint).toHaveBeenCalledWith( - 'asmCustomerSearch' + expect(occEnpointsService.buildUrl).toHaveBeenCalledWith( + 'asmCustomerSearch', + {}, + { prefix: false, baseSite: false } ); }); @@ -167,8 +179,10 @@ describe('OccAsmAdapter', () => { expect(converterService.pipeable).toHaveBeenCalledWith( CUSTOMER_SEARCH_PAGE_NORMALIZER ); - expect(occEnpointsService.getRawEndpoint).toHaveBeenCalledWith( - 'asmCustomerSearch' + expect(occEnpointsService.buildUrl).toHaveBeenCalledWith( + 'asmCustomerSearch', + {}, + { prefix: false, baseSite: false } ); }); }); diff --git a/projects/core/src/occ/adapters/asm/occ-asm.adapter.ts b/projects/core/src/occ/adapters/asm/occ-asm.adapter.ts index a5d4a6707cb..cdaa0eca1d7 100644 --- a/projects/core/src/occ/adapters/asm/occ-asm.adapter.ts +++ b/projects/core/src/occ/adapters/asm/occ-asm.adapter.ts @@ -52,7 +52,11 @@ export class OccAsmAdapter implements AsmAdapter { params = params.set('pageSize', '' + options.pageSize); } - const url = this.occEndpointsService.getRawEndpoint('asmCustomerSearch'); + const url = this.occEndpointsService.buildUrl( + 'asmCustomerSearch', + {}, + { prefix: false, baseSite: false } + ); return this.http .get(url, { headers, params }) diff --git a/projects/core/src/occ/adapters/checkout/occ-checkout-cost-center.adapter.spec.ts b/projects/core/src/occ/adapters/checkout/occ-checkout-cost-center.adapter.spec.ts index a57f4c1c174..246ef4f7073 100644 --- a/projects/core/src/occ/adapters/checkout/occ-checkout-cost-center.adapter.spec.ts +++ b/projects/core/src/occ/adapters/checkout/occ-checkout-cost-center.adapter.spec.ts @@ -4,7 +4,7 @@ import { } from '@angular/common/http/testing'; import { Type } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { ConverterService, Cart } from '@spartacus/core'; +import { Cart, ConverterService } from '@spartacus/core'; import { OccConfig } from '../../index'; import { OccCheckoutCostCenterAdapter } from './occ-checkout-cost-center.adapter'; diff --git a/projects/core/src/occ/adapters/checkout/occ-checkout-payment-type.adapter.spec.ts b/projects/core/src/occ/adapters/checkout/occ-checkout-payment-type.adapter.spec.ts index e315ebd897d..9badeee4041 100644 --- a/projects/core/src/occ/adapters/checkout/occ-checkout-payment-type.adapter.spec.ts +++ b/projects/core/src/occ/adapters/checkout/occ-checkout-payment-type.adapter.spec.ts @@ -5,9 +5,9 @@ import { import { Type } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { + Cart, ConverterService, PAYMENT_TYPE_NORMALIZER, - Cart, } from '@spartacus/core'; import { Occ, OccConfig } from '../../index'; import { OccCheckoutPaymentTypeAdapter } from './occ-checkout-payment-type.adapter'; diff --git a/projects/core/src/occ/adapters/site-context/occ-site.adapter.spec.ts b/projects/core/src/occ/adapters/site-context/occ-site.adapter.spec.ts index 712e951c4a3..7c2f83431ab 100644 --- a/projects/core/src/occ/adapters/site-context/occ-site.adapter.spec.ts +++ b/projects/core/src/occ/adapters/site-context/occ-site.adapter.spec.ts @@ -15,7 +15,11 @@ import { import { defaultOccConfig } from '../../config/default-occ-config'; import { OccConfig } from '../../config/occ-config'; import { Occ } from '../../occ-models/occ.models'; -import { OccEndpointsService } from '../../services'; +import { + BaseOccUrlProperties, + DynamicAttributes, + OccEndpointsService, +} from '../../services'; import { OccSiteAdapter } from './occ-site.adapter'; const MockOccModuleConfig: OccConfig = { @@ -31,7 +35,7 @@ const MockOccModuleConfig: OccConfig = { }, }; -class MockOccEndpointsService { +class MockOccEndpointsService implements Partial { getUrl(endpoint: string, _urlParams?: object, _queryParams?: object) { return this.getEndpoint(endpoint); } @@ -45,8 +49,12 @@ class MockOccEndpointsService { MockOccModuleConfig.context.baseSite ); } - getOccEndpoint(url: string) { - return url; + buildUrl( + endpoint: string, + _attributes?: DynamicAttributes, + _propertiesToOmit?: BaseOccUrlProperties + ) { + return endpoint; } } @@ -73,7 +81,7 @@ describe('OccSiteAdapter', () => { occEndpointsService = TestBed.inject(OccEndpointsService); spyOn(converterService, 'pipeableMany').and.callThrough(); spyOn(occEndpointsService, 'getUrl').and.callThrough(); - spyOn(occEndpointsService, 'getOccEndpoint').and.callThrough(); + spyOn(occEndpointsService, 'buildUrl').and.callThrough(); }); afterEach(() => { @@ -295,8 +303,10 @@ describe('OccSiteAdapter', () => { expect(mockReq.cancelled).toBeFalsy(); expect(mockReq.request.responseType).toEqual('json'); - expect(occEndpointsService.getOccEndpoint).toHaveBeenCalledWith( - 'baseSites' + expect(occEndpointsService.buildUrl).toHaveBeenCalledWith( + 'baseSites', + {}, + { baseSite: false } ); mockReq.flush({ baseSites: baseSites }); }); diff --git a/projects/core/src/occ/adapters/site-context/occ-site.adapter.ts b/projects/core/src/occ/adapters/site-context/occ-site.adapter.ts index 5e299facb7f..e98ba825bc9 100644 --- a/projects/core/src/occ/adapters/site-context/occ-site.adapter.ts +++ b/projects/core/src/occ/adapters/site-context/occ-site.adapter.ts @@ -81,7 +81,7 @@ export class OccSiteAdapter implements SiteAdapter { return this.http .get<{ baseSites: BaseSite[] }>( - this.occEndpointsService.getOccEndpoint('baseSites') + this.occEndpointsService.buildUrl('baseSites', {}, { baseSite: false }) ) .pipe( map((siteList) => { @@ -93,7 +93,7 @@ export class OccSiteAdapter implements SiteAdapter { loadBaseSites(): Observable { return this.http .get<{ baseSites: BaseSite[] }>( - this.occEndpointsService.getOccEndpoint('baseSites') + this.occEndpointsService.buildUrl('baseSites', {}, { baseSite: false }) ) .pipe( map((baseSiteList) => baseSiteList.baseSites), diff --git a/projects/core/src/occ/config-loader/occ-sites-config-loader.ts b/projects/core/src/occ/config-loader/occ-sites-config-loader.ts index 3cee3ad16ff..a228627cd98 100644 --- a/projects/core/src/occ/config-loader/occ-sites-config-loader.ts +++ b/projects/core/src/occ/config-loader/occ-sites-config-loader.ts @@ -13,10 +13,18 @@ export class OccSitesConfigLoader { protected readonly endpoint = 'basesites?fields=baseSites(uid,defaultLanguage(isocode),urlEncodingAttributes,urlPatterns,stores(currencies(isocode),defaultCurrency(isocode),languages(isocode),defaultLanguage(isocode)))'; + private getPrefix(): string { + if ( + Boolean(this.config.backend?.occ?.prefix) && + !this.config.backend?.occ?.prefix?.startsWith('/') + ) { + return '/' + this.config.backend.occ.prefix; + } + return this.config.backend.occ.prefix; + } + private get baseEndpoint(): string { - return ( - (this.config.backend.occ.baseUrl || '') + this.config.backend.occ.prefix - ); + return (this.config.backend.occ.baseUrl || '') + this.getPrefix(); } private get url(): string { diff --git a/projects/core/src/occ/index.ts b/projects/core/src/occ/index.ts index 20adbfc3a08..038e7813d3c 100644 --- a/projects/core/src/occ/index.ts +++ b/projects/core/src/occ/index.ts @@ -8,5 +8,4 @@ export * from './interceptors/index'; export * from './occ-models/index'; export * from './occ.module'; export * from './services/index'; -export * from './utils/interceptor-util'; -export * from './utils/occ-constants'; +export * from './utils/index'; diff --git a/projects/core/src/occ/occ-models/occ-endpoints.model.ts b/projects/core/src/occ/occ-models/occ-endpoints.model.ts index b7bdb5661fc..4831ca71d35 100644 --- a/projects/core/src/occ/occ-models/occ-endpoints.model.ts +++ b/projects/core/src/occ/occ-models/occ-endpoints.model.ts @@ -115,24 +115,6 @@ export interface OccEndpoints { * @member {string} [addEmail] */ addEmail?: string | OccEndpoint; - /** - * Get a store location - * - * @member {string} [page] - */ - store?: string | OccEndpoint; - /** - * Get a list of store locations - * - * @member {string} [page] - */ - stores?: string | OccEndpoint; - /** - * Gets a store location count per country and regions - * - * @member {string} [page] - */ - storescounts?: string | OccEndpoint; /** * Get a list of available languages * @@ -353,6 +335,66 @@ export interface OccEndpoints { * Endpoint for place order */ placeOrder?: string | OccEndpoint; + /** + * Endpoint to schedule a replenishment order + * + * * @member {string} + */ + scheduleReplenishmentOrder?: string | OccEndpoint; + /** + * * Endpoint for the list of one user's replenishment orders + * + * * @member {string} + */ + replenishmentOrderHistory?: string | OccEndpoint; + /* Endpoint to get a replenishment order details + * + * * @member {string} + */ + replenishmentOrderDetails?: string | OccEndpoint; + /** + * Endpoint to get a replenishment order history for a replenishment + * + * * @member {string} + */ + replenishmentOrderDetailsHistory?: string | OccEndpoint; + /** + * Endpoint to get a replenishment order history for a replenishment + * + * * @member {string} + */ + cancelReplenishmentOrder?: string | OccEndpoint; + /** + * Endpoint for getting all base sites + * + * @member {string} + */ + baseSites?: string | OccEndpoint; + /** Endpoint to returns active cost centers + * + * @member {string} + */ + getActiveCostCenters?: string | OccEndpoint; + + // TODO @deprecation for 3.2 DEPRECATION START - The endpoint bellow were moved to separate feature libraries + /** + * Get a store location + * + * @member {string} [page] + */ + store?: string | OccEndpoint; + /** + * Get a list of store locations + * + * @member {string} [page] + */ + stores?: string | OccEndpoint; + /** + * Gets a store location count per country and regions + * + * @member {string} [page] + */ + storescounts?: string | OccEndpoint; /** * Endpoint for userGroupOrderApprovalPermission * @@ -491,35 +533,6 @@ export interface OccEndpoints { * @member {string} */ costCenters?: string | OccEndpoint; - /** - * Endpoint to schedule a replenishment order - * - * * @member {string} - */ - scheduleReplenishmentOrder?: string | OccEndpoint; - /** - * * Endpoint for the list of one user's replenishment orders - * - * * @member {string} - */ - replenishmentOrderHistory?: string | OccEndpoint; - /* Endpoint to get a replenishment order details - * - * * @member {string} - */ - replenishmentOrderDetails?: string | OccEndpoint; - /** - * Endpoint to get a replenishment order history for a replenishment - * - * * @member {string} - */ - replenishmentOrderDetailsHistory?: string | OccEndpoint; - /** - * Endpoint to get a replenishment order history for a replenishment - * - * * @member {string} - */ - cancelReplenishmentOrder?: string | OccEndpoint; /** * Endpoint for all costCenters * @@ -628,15 +641,5 @@ export interface OccEndpoints { * @member {string} */ orderApprovalDecision?: string | OccEndpoint; - /** - * Endpoint for getting all base sites - * - * @member {string} - */ - baseSites?: string | OccEndpoint; - /** Endpoint to returns active cost centers - * - * @member {string} - */ - getActiveCostCenters?: string | OccEndpoint; + // DEPRECATION END } diff --git a/projects/core/src/occ/services/occ-endpoints.service.spec.ts b/projects/core/src/occ/services/occ-endpoints.service.spec.ts index b96a60838c7..3023db671d3 100644 --- a/projects/core/src/occ/services/occ-endpoints.service.spec.ts +++ b/projects/core/src/occ/services/occ-endpoints.service.spec.ts @@ -55,6 +55,13 @@ describe('OccEndpointsService', () => { ); }); + it('should return raw endpoint value', () => { + const occ = mockOccConfig.backend.occ; + expect(service.getRawEndpointValue('asmCustomerSearch')).toEqual( + occ.endpoints['asmCustomerSearch'].toString() + ); + }); + it('should return occ endpoint', () => { const occ = mockOccConfig.backend.occ; expect(service.getOccEndpoint('asmCustomerSearch')).toEqual( @@ -202,4 +209,153 @@ describe('OccEndpointsService', () => { ); }); }); + + describe('getBaseUrl', () => { + it('should return base endpoint by default', () => { + expect(service.getBaseUrl()).toEqual(baseEndpoint); + }); + + it('should be immune to late baseSite default value in config', () => { + const config = TestBed.inject(OccConfig); + expect(service.getBaseUrl()).toEqual(baseEndpoint); + // we are modifying config as it can happen before app initialization in config initializer + config.context.baseSite = ['/final-baseSite']; + expect(service.getBaseUrl()).toEqual( + 'test-baseUrl/test-occPrefix/final-baseSite' + ); + }); + + it('should return the base url based on the provided parameters', () => { + expect(service.getBaseUrl({ prefix: false })).toEqual( + 'test-baseUrl/test-baseSite' + ); + expect(service.getBaseUrl({ prefix: false, baseSite: true })).toEqual( + 'test-baseUrl/test-baseSite' + ); + expect(service.getBaseUrl({ baseSite: false })).toEqual( + 'test-baseUrl/test-occPrefix' + ); + }); + }); + + describe('buildUrl', () => { + it('should return endpoint from config', () => { + const url = service.buildUrl('product'); + + expect(url).toEqual( + baseEndpoint + '/configured-endpoint1/${test}?fields=abc' + ); + }); + + describe('using scope', () => { + it('should return endpoint from config', () => { + const url = service.buildUrl('product', { + scope: 'test', + }); + + expect(url).toEqual( + baseEndpoint + '/configured-endpoint1/${test}?fields=test' + ); + }); + + it('should fallback to default scope', () => { + const url = service.buildUrl('product', { + scope: 'test-non-existing', + }); + + expect(url).toEqual( + baseEndpoint + '/configured-endpoint1/${test}?fields=abc' + ); + }); + + it('should not resolve endpoint for missing scope when no default is specified', () => { + const config = TestBed.inject(OccConfig); + delete config.backend.occ.endpoints.product; + + const url = service.buildUrl('product', { + scope: 'test-non-existing', + }); + + expect(url).toBe('test-baseUrl/test-occPrefix/test-baseSite/product'); + }); + + it('should use string configuration for backward compatibility', () => { + const config = TestBed.inject(OccConfig); + config.backend.occ.endpoints.product = + 'configured-endpoint1/${test}?fields=fallback'; + + const url = service.buildUrl('product', { + scope: 'test-non-existing', + }); + + expect(url).toBe( + 'test-baseUrl/test-occPrefix/test-baseSite/configured-endpoint1/${test}?fields=fallback' + ); + }); + }); + + it('should apply parameters to configured endpoint', () => { + const url = service.buildUrl('product', { + urlParams: { test: 'test-value' }, + }); + expect(url).toEqual( + baseEndpoint + '/configured-endpoint1/test-value?fields=abc' + ); + }); + + it('should add query parameters to configured endpoint', () => { + const url = service.buildUrl('product', { + urlParams: { test: 'test-value' }, + queryParams: { param: 'test-param' }, + }); + + expect(url).toEqual( + baseEndpoint + + '/configured-endpoint1/test-value?fields=abc¶m=test-param' + ); + }); + + it('should allow to redefine preconfigured query parameters', () => { + const url = service.buildUrl('product', { + urlParams: { test: 'test-value' }, + queryParams: { fields: 'xyz' }, + }); + + expect(url).toEqual( + baseEndpoint + '/configured-endpoint1/test-value?fields=xyz' + ); + }); + + it('should allow to remove preconfigured query parameters', () => { + const url = service.buildUrl('product', { + urlParams: { test: 'test-value' }, + queryParams: { fields: null }, + }); + + expect(url).toEqual(baseEndpoint + '/configured-endpoint1/test-value'); + }); + + it('should escape special characters passed in url params', () => { + const url = service.buildUrl('product', { + urlParams: { test: 'ąćę$%' }, + queryParams: { fields: null }, + }); + + expect(url).toEqual( + baseEndpoint + '/configured-endpoint1/%C4%85%C4%87%C4%99%24%25' + ); + }); + + it('should escape query parameters', () => { + const url = service.buildUrl('product', { + urlParams: { test: 'test-value' }, + queryParams: { fields: '+./.\\.,.?' }, + }); + + expect(url).toEqual( + baseEndpoint + + '/configured-endpoint1/test-value?fields=%2B.%2F.%5C.%2C.%3F' + ); + }); + }); }); diff --git a/projects/core/src/occ/services/occ-endpoints.service.ts b/projects/core/src/occ/services/occ-endpoints.service.ts index 94f3eee19f1..5f8f4db931d 100644 --- a/projects/core/src/occ/services/occ-endpoints.service.ts +++ b/projects/core/src/occ/services/occ-endpoints.service.ts @@ -7,6 +7,19 @@ import { BASE_SITE_CONTEXT_ID } from '../../site-context/providers/context-ids'; import { HttpParamsURIEncoder } from '../../util/http-params-uri.encoder'; import { OccConfig } from '../config/occ-config'; import { DEFAULT_SCOPE } from '../occ-models/occ-endpoints.model'; +import { urlPathJoin } from '../utils/occ-url-util'; + +export interface BaseOccUrlProperties { + baseUrl?: boolean; + prefix?: boolean; + baseSite?: boolean; +} + +export interface DynamicAttributes { + urlParams?: object; + queryParams?: object; + scope?: string; +} @Injectable({ providedIn: 'root', @@ -33,6 +46,8 @@ export class OccEndpointsService { } /** + * @Deprecated since 3.2 - use "getRawEndpointValue" or "buildUrl" instead + * * Returns an endpoint starting from the OCC baseUrl (no baseSite) * @param endpoint Endpoint suffix */ @@ -50,6 +65,20 @@ export class OccEndpointsService { } /** + * Returns the value configured for a specific endpoint + * + * @param endpointKey the configuration key for the endpoint to return + * @param scope endpoint configuration scope + */ + getRawEndpointValue(endpoint: string, scope?: string): string { + const endpointValue = this.getEndpointForScope(endpoint, scope); + + return endpointValue; + } + + /** + * @Deprecated since 3.2 - use "buildUrl" instead + * * Returns an endpoint starting from the OCC prefix (no baseSite), i.e. /occ/v2/{endpoint} * Most OCC endpoints are related to a baseSite context and are therefor prefixed * with the baseSite. The `/basesites` endpoint does not relate to a specific baseSite @@ -58,52 +87,125 @@ export class OccEndpointsService { * @param endpoint Endpoint suffix */ getOccEndpoint(endpoint: string): string { - if (!this.config?.backend?.occ) { - return ''; - } - endpoint = this.config.backend.occ.endpoints?.[endpoint]; - - if ( - !endpoint.startsWith('/') && - !this.config.backend.occ.prefix.endsWith('/') - ) { - endpoint = '/' + endpoint; - } + endpoint = this.getRawEndpointValue(endpoint); - return ( - this.config.backend.occ.baseUrl + - this.config.backend.occ.prefix + - endpoint - ); + return this.getEndpoint(endpoint, { baseSite: false }); } /** - * Returns base OCC endpoint (baseUrl + prefix + baseSite) + * @Deprecated since 3.2 - use "getBaseUrl" with the same parameters + * + * Returns base OCC endpoint (baseUrl + prefix + baseSite) by if no parameters are specified + * + * @param propertiesToOmit Specify properties to not add to the url (baseUrl, prefix, baseSite) */ - getBaseEndpoint(): string { + getBaseEndpoint(propertiesToOmit?: BaseOccUrlProperties): string { if (!this.config?.backend?.occ) { return ''; } - return ( - (this.config.backend.occ.baseUrl || '') + - this.config.backend.occ.prefix + - this.activeBaseSite - ); + return this.getBaseUrl(propertiesToOmit); } /** + * @Deprecated since 3.2 - use "buildUrl" with configurable endpoints instead + * * Returns an OCC endpoint including baseUrl and baseSite + * * @param endpoint Endpoint suffix + * @param propertiesToOmit Specify properties to not add to the url (baseUrl, prefix, baseSite) */ - getEndpoint(endpoint: string): string { - if (!endpoint.startsWith('/')) { + getEndpoint( + endpoint: string, + propertiesToOmit?: BaseOccUrlProperties + ): string { + if (!endpoint.startsWith('/') && !this.getPrefix().endsWith('/')) { endpoint = '/' + endpoint; } - return this.getBaseEndpoint() + endpoint; + return this.buildUrlFromEndpointString(endpoint, propertiesToOmit); + } + + /** + * Returns base OCC endpoint (baseUrl + prefix + baseSite) base on provided values + * + * @param baseUrlProperties Specify properties to not add to the url (baseUrl, prefix, baseSite) + */ + getBaseUrl( + baseUrlProperties: BaseOccUrlProperties = { + baseUrl: true, + prefix: true, + baseSite: true, + } + ): string { + const baseUrl = + baseUrlProperties.baseUrl === false + ? '' + : this.config.backend.occ.baseUrl; + const prefix = baseUrlProperties.prefix === false ? '' : this.getPrefix(); + const baseSite = + baseUrlProperties.baseSite === false ? '' : this.activeBaseSite; + + return urlPathJoin(baseUrl, prefix, baseSite); + } + + /** + * Returns a fully qualified OCC Url + * + * @param endpoint Name of the OCC endpoint key + * @param attributes Dynamic attributes used to build the url + * @param propertiesToOmit Specify properties to not add to the url (baseUrl, prefix, baseSite) + */ + buildUrl( + endpoint: string, + attributes?: DynamicAttributes, + propertiesToOmit?: BaseOccUrlProperties + ): string { + let url = this.getEndpointForScope(endpoint, attributes?.scope); + + if (attributes) { + const { urlParams, queryParams } = attributes; + + if (urlParams) { + url = DynamicTemplate.resolve(url, attributes.urlParams, true); + } + + if (queryParams) { + let httpParamsOptions = { encoder: new HttpParamsURIEncoder() }; + + if (url.includes('?')) { + let queryParamsFromEndpoint: string; + [url, queryParamsFromEndpoint] = url.split('?'); + httpParamsOptions = { + ...httpParamsOptions, + ...{ fromString: queryParamsFromEndpoint }, + }; + } + + let httpParams = new HttpParams(httpParamsOptions); + Object.keys(queryParams).forEach((key) => { + const value = queryParams[key]; + if (value !== undefined) { + if (value === null) { + httpParams = httpParams.delete(key); + } else { + httpParams = httpParams.set(key, value); + } + } + }); + + const params = httpParams.toString(); + if (params.length) { + url += '?' + params; + } + } + } + + return this.buildUrlFromEndpointString(url, propertiesToOmit); } /** + * @Deprecated since 3.2 - use "buildUrl" instead + * * Returns a fully qualified OCC Url (including baseUrl and baseSite) * @param endpoint Name of the OCC endpoint key config * @param urlParams URL parameters @@ -129,7 +231,7 @@ export class OccEndpointsService { let httpParamsOptions = { encoder: new HttpParamsURIEncoder() }; if (endpoint.includes('?')) { - let queryParamsFromEndpoint; + let queryParamsFromEndpoint: string; [endpoint, queryParamsFromEndpoint] = endpoint.split('?'); httpParamsOptions = { @@ -161,6 +263,11 @@ export class OccEndpointsService { private getEndpointForScope(endpoint: string, scope?: string): string { const endpointsConfig = this.config.backend?.occ?.endpoints; + + if (!Boolean(endpointsConfig)) { + return ''; + } + const endpointConfig = endpointsConfig[endpoint]; if (scope) { @@ -183,4 +290,27 @@ export class OccEndpointsService { : endpointConfig?.[DEFAULT_SCOPE]) || endpoint ); } + + /** + * Add the base OCC url properties to the specified endpoint string + * + * @param endpointString String value for the url endpoint + * @param propertiesToOmit Specify properties to not add to the url (baseUrl, prefix, baseSite) + */ + private buildUrlFromEndpointString( + endpointString: string, + propertiesToOmit?: BaseOccUrlProperties + ): string { + return urlPathJoin(this.getBaseUrl(propertiesToOmit), endpointString); + } + + private getPrefix(): string { + if ( + this.config?.backend?.occ?.prefix && + !this.config.backend.occ.prefix.startsWith('/') + ) { + return '/' + this.config.backend.occ.prefix; + } + return this.config.backend.occ.prefix; + } } diff --git a/projects/core/src/occ/utils/index.ts b/projects/core/src/occ/utils/index.ts new file mode 100644 index 00000000000..833172c7421 --- /dev/null +++ b/projects/core/src/occ/utils/index.ts @@ -0,0 +1,3 @@ +export * from './interceptor-util'; +export * from './occ-constants'; +export * from './occ-url-util'; diff --git a/projects/core/src/occ/utils/occ-url-util.spec.ts b/projects/core/src/occ/utils/occ-url-util.spec.ts new file mode 100644 index 00000000000..a191a23797d --- /dev/null +++ b/projects/core/src/occ/utils/occ-url-util.spec.ts @@ -0,0 +1,31 @@ +import { urlPathJoin } from './occ-url-util'; + +describe('urlPathJoin', () => { + it('should join parts', () => { + expect(urlPathJoin('test1', 'test2', 'test3')).toEqual('test1/test2/test3'); + }); + + it('should omit empty, null and undefined values', () => { + expect(urlPathJoin('test1', '', 'test2', null, undefined, 'test3')).toEqual( + 'test1/test2/test3' + ); + }); + + it('should NOT double slashes', () => { + expect(urlPathJoin('test1/', '/test2', '/test3')).toEqual( + 'test1/test2/test3' + ); + }); + + it('should keep correct double slashes', () => { + expect(urlPathJoin('https://test123', '/occ/v2/', 'test3')).toEqual( + 'https://test123/occ/v2/test3' + ); + }); + + it('should keep preceding and trailing slashes', () => { + expect(urlPathJoin('/test1/', 'test2', 'test3/')).toEqual( + '/test1/test2/test3/' + ); + }); +}); diff --git a/projects/core/src/occ/utils/occ-url-util.ts b/projects/core/src/occ/utils/occ-url-util.ts new file mode 100644 index 00000000000..87eb0679268 --- /dev/null +++ b/projects/core/src/occ/utils/occ-url-util.ts @@ -0,0 +1,27 @@ +/** + * Joins the multiple parts with '/' to create a url + * + * @param parts the distinct parts of the url to join + */ +export function urlPathJoin(...parts: string[]): string { + const paths: string[] = []; + parts = parts.filter((part) => Boolean(part)); + for (const part of parts) { + paths.push(cleanSlashes(part)); + } + + if (parts[0]?.startsWith('/')) { + paths[0] = '/' + paths[0]; + } + if (parts[parts.length - 1]?.endsWith('/')) { + paths[paths.length - 1] = paths[paths.length - 1] + '/'; + } + return paths.join('/'); +} + +function cleanSlashes(path: string): string { + path = path.startsWith('/') ? path.slice(1) : path; + path = path.endsWith('/') ? path.slice(0, -1) : path; + + return path; +} From 77452a300081d84b57f96fd8c199fb6862bfd871 Mon Sep 17 00:00:00 2001 From: Stan Date: Fri, 15 Jan 2021 20:03:15 +0100 Subject: [PATCH 04/30] fix: Deferred Loading still doesn't work properly in SSR mode (#10718) Closes #8089 --- .../src/cms-structure/page/slot/page-slot.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/storefrontlib/src/cms-structure/page/slot/page-slot.service.ts b/projects/storefrontlib/src/cms-structure/page/slot/page-slot.service.ts index 7c74a748c0e..d61c1115c72 100644 --- a/projects/storefrontlib/src/cms-structure/page/slot/page-slot.service.ts +++ b/projects/storefrontlib/src/cms-structure/page/slot/page-slot.service.ts @@ -31,7 +31,7 @@ export class PageSlotService { el.getBoundingClientRect().top < this.document.documentElement.clientHeight ) - .map((el: Element) => el.getAttribute('page-slot')); + .map((el: Element) => el.getAttribute('position')); } } From 936cf548be9b07900af9bd39dfef59c7e83f36bf Mon Sep 17 00:00:00 2001 From: Jerry Wang <58975336+wangzixi-diablo@users.noreply.github.com> Date: Mon, 18 Jan 2021 20:29:52 +0800 Subject: [PATCH 05/30] add missing route config for cost center (#10740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michał Gruca --- .../components/cost-center/cost-center.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/feature-libs/organization/administration/components/cost-center/cost-center.config.ts b/feature-libs/organization/administration/components/cost-center/cost-center.config.ts index 9241efae174..986b6356ab0 100644 --- a/feature-libs/organization/administration/components/cost-center/cost-center.config.ts +++ b/feature-libs/organization/administration/components/cost-center/cost-center.config.ts @@ -32,6 +32,9 @@ const paramsMapping: ParamsMapping = { export const costCenterRoutingConfig: RoutingConfig = { routing: { routes: { + orgCostCenters: { + paths: ['/organization/cost-centers'], + }, orgCostCenterCreate: { paths: ['organization/cost-centers/create'], }, From abd225bcd3e9bcf53ed8e65251769779b443d3c5 Mon Sep 17 00:00:00 2001 From: Jerry Wang <58975336+wangzixi-diablo@users.noreply.github.com> Date: Mon, 18 Jan 2021 23:52:00 +0800 Subject: [PATCH 06/30] remove unnecessary s in cost center config (#10756) --- .../administration/components/cost-center/cost-center.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature-libs/organization/administration/components/cost-center/cost-center.config.ts b/feature-libs/organization/administration/components/cost-center/cost-center.config.ts index 986b6356ab0..10b6bd714bd 100644 --- a/feature-libs/organization/administration/components/cost-center/cost-center.config.ts +++ b/feature-libs/organization/administration/components/cost-center/cost-center.config.ts @@ -32,7 +32,7 @@ const paramsMapping: ParamsMapping = { export const costCenterRoutingConfig: RoutingConfig = { routing: { routes: { - orgCostCenters: { + orgCostCenter: { paths: ['/organization/cost-centers'], }, orgCostCenterCreate: { From e6e7309e9512825a76f5d5d69dfbd4888f599447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miros=C5=82aw=20Grochowski?= Date: Tue, 19 Jan 2021 10:28:13 +0100 Subject: [PATCH 07/30] fix: no border for selected style variant (#10724) fixes #10273 --- .../product/details/_product-variants.scss | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/projects/storefrontstyles/scss/components/product/details/_product-variants.scss b/projects/storefrontstyles/scss/components/product/details/_product-variants.scss index 2a8fd7391e2..43fcdd223cd 100644 --- a/projects/storefrontstyles/scss/components/product/details/_product-variants.scss +++ b/projects/storefrontstyles/scss/components/product/details/_product-variants.scss @@ -23,12 +23,6 @@ li { display: inline-block; - &.selected-variant { - img { - border: 2px solid var(--cx-color-primary); - } - } - .variant-button { @include button-reset(); @@ -41,6 +35,12 @@ border: 1px solid #ddd; } } + + &.selected-variant { + img { + border: 2px solid var(--cx-color-primary); + } + } } } } From 0b9181614195011bc4f65ab4bcffba95dca79fb8 Mon Sep 17 00:00:00 2001 From: Gilberto Alvarado Date: Tue, 19 Jan 2021 05:56:37 -0500 Subject: [PATCH 08/30] Incorporate b2b tests to CI (#10693) Closes #10304 --- .github/ISSUE_TEMPLATE/new-release.md | 2 +- .travis.yml | 3 +- ci-scripts/e2e-cypress.sh | 39 +++++++------------ package.json | 16 ++------ .../cypress.ci.2005.json | 2 +- .../cypress.ci.b2b.json | 2 +- .../storefrontapp-e2e-cypress/cypress.json | 2 +- ...ts.e2e-spec.ts => units.flaky-e2e-spec.ts} | 0 ...rs.e2e-spec.ts => users.flaky-e2e-spec.ts} | 0 .../checkout/checkout-as-guest.e2e-spec.ts | 33 +--------------- .../checkout}/checkout-flow.e2e-spec.ts | 2 +- ....ts => express-checkout.flaky-e2e-spec.ts} | 0 .../homepage.e2e-spec.ts | 0 .../{smoke => regression}/login.e2e-spec.ts | 0 .../{smoke => regression}/outlets.e2e-spec.ts | 0 .../product-search.e2e-spec.ts | 2 +- .../{smoke => regression}/routing.e2e-spec.ts | 0 .../currency}/currency.e2e-spec.ts | 4 +- ...s => language-cart-page.flaky-e2e-spec.ts} | 0 .../language}/language.e2e-spec.ts | 2 +- .../cypress/sample-data/b2b-order-approval.ts | 4 +- .../cypress/sample-data/shared-users.ts | 2 +- .../storefrontapp-e2e-cypress/package.json | 11 ++---- .../src/environments/environment.prod.ts | 2 +- .../src/environments/environment.ts | 4 +- 25 files changed, 40 insertions(+), 92 deletions(-) rename projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/{units.e2e-spec.ts => units.flaky-e2e-spec.ts} (100%) rename projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/{users.e2e-spec.ts => users.flaky-e2e-spec.ts} (100%) rename projects/storefrontapp-e2e-cypress/cypress/integration/{smoke => regression/checkout}/checkout-flow.e2e-spec.ts (92%) rename projects/storefrontapp-e2e-cypress/cypress/integration/regression/checkout/{express-checkout.e2e-spec.ts => express-checkout.flaky-e2e-spec.ts} (100%) rename projects/storefrontapp-e2e-cypress/cypress/integration/{smoke => regression}/homepage.e2e-spec.ts (100%) rename projects/storefrontapp-e2e-cypress/cypress/integration/{smoke => regression}/login.e2e-spec.ts (100%) rename projects/storefrontapp-e2e-cypress/cypress/integration/{smoke => regression}/outlets.e2e-spec.ts (100%) rename projects/storefrontapp-e2e-cypress/cypress/integration/{smoke => regression/product-search}/product-search.e2e-spec.ts (96%) rename projects/storefrontapp-e2e-cypress/cypress/integration/{smoke => regression}/routing.e2e-spec.ts (100%) rename projects/storefrontapp-e2e-cypress/cypress/integration/{smoke => regression/site-context/currency}/currency.e2e-spec.ts (90%) rename projects/storefrontapp-e2e-cypress/cypress/integration/regression/site-context/language/{language-cart-page.e2e-spec.ts => language-cart-page.flaky-e2e-spec.ts} (100%) rename projects/storefrontapp-e2e-cypress/cypress/integration/{smoke => regression/site-context/language}/language.e2e-spec.ts (89%) diff --git a/.github/ISSUE_TEMPLATE/new-release.md b/.github/ISSUE_TEMPLATE/new-release.md index a0f3df943f5..d5b5be1ac6c 100644 --- a/.github/ISSUE_TEMPLATE/new-release.md +++ b/.github/ISSUE_TEMPLATE/new-release.md @@ -36,7 +36,7 @@ assignees: '' Once finished, run `./run.sh start` to start the apps and check that they are working. You can also go to each app directory and run it with `yarn build`, `start`, `build:ssr`, etc. -- [ ] Run all e2e tests on this latest build (Pro tip: run mobile, regression, smoke scripts in parallel to get all the results faster, after that retry failed tests in open mode) +- [ ] Run all e2e tests on this latest build (Pro tip: run mobile, regression scripts in parallel to get all the results faster, after that retry failed tests in open mode) --- diff --git a/.travis.yml b/.travis.yml index 6c87a733cb0..a6992b574d5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,9 +11,10 @@ branches: - "/^epic\\/.*$/" - "/^release\\/.*$/" defaults: &defaults - script: "./ci-scripts/e2e-cypress.sh -s regression" + script: "./ci-scripts/e2e-cypress.sh" jobs: - script: "./build.sh sonar" + - script: "./ci-scripts/e2e-cypress.sh -s b2b" - script: name: "Cypress regression tests, 1st job" env: STAGE_NAME=spa-ci-regression diff --git a/ci-scripts/e2e-cypress.sh b/ci-scripts/e2e-cypress.sh index f6efe8fa474..06ac1667756 100755 --- a/ci-scripts/e2e-cypress.sh +++ b/ci-scripts/e2e-cypress.sh @@ -7,17 +7,17 @@ POSITIONAL=() readonly help_display="Usage: $0 [ command_options ] [ param ] command options: - --suite, -s choose an e2e suite to run. Default: regression - --integration, -i run the correct e2e integration suite. Default: "" for smoke tests - --environment, --env [1905 | 2005 | ccv2]. Default: 1905 - --help, -h show this message and exit + --suite, -s choose an e2e suite to run. Default: b2c + --integration, -i run an additional e2e integration suite (cds, cdc, etc) + --environment, --env [ 2005 | 2011 | ccv2]. Default: 2005 + --help, -h show help " while [ "${1:0:1}" == "-" ] do case "$1" in '--suite' | '-s' ) - SUITE=$2 + SUITE=":$2" shift shift ;; @@ -47,17 +47,17 @@ done set -- "${POSITIONAL[@]}" - if [[ -z "${CI_ENV}" ]]; then CI_ENV=":2005" fi -yarn -(cd projects/storefrontapp-e2e-cypress && yarn) - echo '-----' -echo 'Building Spartacus libraries' -# Currently for our unified app you have to build all libraries to run it +echo "Building Spartacus libraries" + +yarn install + +(cd projects/storefrontapp-e2e-cypress && yarn install) + yarn build:libs && yarn build"${INTEGRATION}" 2>&1 | tee build.log results=$(grep "Warning: Can't resolve all parameters for" build.log || true) @@ -70,18 +70,9 @@ else exit 1 fi +echo '-----' +echo "Running Cypress end to end tests" -# Hardcoded 2005 becuase cypress.ci.b2b.json currently supports only 2005. -# TODO: The condition should be removed and logic here simplified, when fixing https://github.com/SAP/spartacus/issues/10160 -SHOULD_RUN_B2B=false; +yarn e2e:cy"${INTEGRATION}":start-run-ci"${CI_ENV}${SUITE}" -echo '-----' -echo "Running Cypress end to end tests for suite: $SUITE" -if [[ $SUITE == 'regression' ]]; then - yarn e2e:cy"${INTEGRATION}":start-run-ci"${CI_ENV}" -else - yarn e2e:cy"${INTEGRATION}":start-run-smoke-ci"${CI_ENV}" - if [[ $SHOULD_RUN_B2B ]]; then - yarn e2e:cy"${INTEGRATION}":start-run-smoke-ci"${CI_ENV}":b2b - fi -fi +echo "Running Cypress end to end tests finished" \ No newline at end of file diff --git a/package.json b/package.json index a2079903eec..baa8f38f721 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,6 @@ "e2e:cy:run:ci:2005:b2b": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:ci:2005:b2b", "e2e:cy:run:ci:ccv2": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:ci:ccv2", "e2e:cy:cds:run:ci:2005": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:cds:run:ci:2005", - "e2e:cy:run:smoke": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:smoke", - "e2e:cy:run:smoke:ci:1905": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:smoke:ci:1905", - "e2e:cy:run:smoke:ci:2005": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:smoke:ci:2005", - "e2e:cy:run:smoke:ci:2005:b2b": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:smoke:ci:2005:b2b", - "e2e:cy:run:smoke:ci:ccv2": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:smoke:ci:ccv2", "e2e:cy:run:mobile": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:mobile", "e2e:cy:run:mobile:ci": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:mobile:ci", "e2e:cy:run:regression": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:regression", @@ -37,11 +32,6 @@ "e2e:cy:start-run-flaky-ci:2005": "start-server-and-test 'yarn start:ci:2005' http-get://localhost:4200 'yarn e2e:cy:run:ci:flaky:2005'", "e2e:cy:start-run-ci:2005:b2b": "start-server-and-test start:ci:b2b http-get://localhost:4200 e2e:cy:run:ci:2005:b2b", "e2e:cy:start-run-ci:ccv2": "start-server-and-test start:ci:ccv2 http-get://localhost:4200 e2e:cy:run:ci:ccv2", - "e2e:cy:start-run-smoke": "start-server-and-test start http-get://localhost:4200 e2e:cy:run:smoke", - "e2e:cy:start-run-smoke-ci:1905": "start-server-and-test start:ci:1905 http-get://localhost:4200 e2e:cy:run:smoke:ci:1905", - "e2e:cy:start-run-smoke-ci:2005": "start-server-and-test start:ci:2005 http-get://localhost:4200 e2e:cy:run:smoke:ci:2005", - "e2e:cy:start-run-smoke-ci:2005:b2b": "start-server-and-test start:ci:b2b http-get://localhost:4200 e2e:cy:run:smoke:ci:2005:b2b", - "e2e:cy:start-run-smoke-ci:ccv2": "start-server-and-test start:ci:ccv2 http-get://localhost:4200 e2e:cy:run:smoke:ci:ccv2", "e2e:cy:start-run-mobile": "start-server-and-test start http-get://localhost:4200 e2e:cy:run:mobile", "e2e:cy:start-run-mobile-ci": "start-server-and-test start:ci:1905 http-get://localhost:4200 e2e:cy:run:mobile:ci", "e2e:cy:start-run-regression": "start-server-and-test start http-get://localhost:4200 e2e:cy:run:regression", @@ -56,8 +46,8 @@ "lint:styles": "stylelint \"{projects,feature-libs}/**/*.scss\"", "start": "ng serve", "start:ci:1905": "cross-env SPARTACUS_BASE_URL=https://spartacus-legacy.eastus.cloudapp.azure.com:9002 SPARTACUS_API_PREFIX=/rest/v2/ yarn start:prod", - "start:ci:2005": "cross-env SPARTACUS_BASE_URL=https://spartacus-dev0.eastus.cloudapp.azure.com:9002 SPARTACUS_API_PREFIX=/occ/v2/ yarn start:prod", - "build:ci:2005": "cross-env SPARTACUS_BASE_URL=https://spartacus-dev0.eastus.cloudapp.azure.com:9002 SPARTACUS_API_PREFIX=/occ/v2/ yarn build --prod", + "start:ci:2005": "cross-env SPARTACUS_BASE_URL=https://spartacus-devci767.eastus.cloudapp.azure.com:9002 SPARTACUS_API_PREFIX=/occ/v2/ yarn start:prod", + "build:ci:2005": "cross-env SPARTACUS_BASE_URL=https://spartacus-devci767.eastus.cloudapp.azure.com:9002 SPARTACUS_API_PREFIX=/occ/v2/ yarn build --prod", "start:ci:ccv2": "cross-env SPARTACUS_BASE_URL=https://spartacus-demo.eastus.cloudapp.azure.com:8443 yarn start:prod", "start:prod": "ng serve --prod", "start:ssl": "ng serve --ssl", @@ -110,7 +100,7 @@ "e2e:cy:run:b2b": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:b2b", "e2e:cy:run:b2b:ci": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:b2b:ci", "e2e:cy:b2b:start-run-ci:2005": "start-server-and-test start:ci:b2b http-get://localhost:4200 e2e:cy:run:b2b:ci", - "start:ci:b2b": "cross-env SPARTACUS_BASE_URL=https://spartacus-dev0.eastus.cloudapp.azure.com:9002 SPARTACUS_API_PREFIX=/occ/v2/ SPARTACUS_B2B=true yarn start:prod", + "start:ci:b2b": "cross-env SPARTACUS_BASE_URL=https://spartacus-devci767.eastus.cloudapp.azure.com:9002 SPARTACUS_API_PREFIX=/occ/v2/ SPARTACUS_B2B=true yarn start", "e2e:cy:open:b2b": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:open:b2b", "config:update": "ts-node ./tools/tsconfig-paths/index.ts" }, diff --git a/projects/storefrontapp-e2e-cypress/cypress.ci.2005.json b/projects/storefrontapp-e2e-cypress/cypress.ci.2005.json index 9fb0626f5d7..b00e5112362 100644 --- a/projects/storefrontapp-e2e-cypress/cypress.ci.2005.json +++ b/projects/storefrontapp-e2e-cypress/cypress.ci.2005.json @@ -12,7 +12,7 @@ "env": { "CLIENT_ID": "mobile_android", "CLIENT_SECRET": "secret", - "API_URL": "https://spartacus-dev0.eastus.cloudapp.azure.com:9002", + "API_URL": "https://spartacus-devci767.eastus.cloudapp.azure.com:9002", "OCC_PREFIX": "/occ/v2", "OCC_PREFIX_USER_ENDPOINT": "users", "OCC_PREFIX_ORDER_ENDPOINT": "orders", diff --git a/projects/storefrontapp-e2e-cypress/cypress.ci.b2b.json b/projects/storefrontapp-e2e-cypress/cypress.ci.b2b.json index 2d1a01c598c..4bd6f14b6f8 100644 --- a/projects/storefrontapp-e2e-cypress/cypress.ci.b2b.json +++ b/projects/storefrontapp-e2e-cypress/cypress.ci.b2b.json @@ -10,7 +10,7 @@ "env": { "CLIENT_ID": "mobile_android", "CLIENT_SECRET": "secret", - "API_URL": "https://spartacus-dev0.eastus.cloudapp.azure.com:9002", + "API_URL": "https://spartacus-devci767.eastus.cloudapp.azure.com:9002", "OCC_PREFIX": "/occ/v2", "OCC_PREFIX_USER_ENDPOINT": "orgUsers", "OCC_PREFIX_ORDER_ENDPOINT": "orders", diff --git a/projects/storefrontapp-e2e-cypress/cypress.json b/projects/storefrontapp-e2e-cypress/cypress.json index 34d8d5d0060..d51c59f1ad4 100644 --- a/projects/storefrontapp-e2e-cypress/cypress.json +++ b/projects/storefrontapp-e2e-cypress/cypress.json @@ -8,7 +8,7 @@ "env": { "CLIENT_ID": "mobile_android", "CLIENT_SECRET": "secret", - "API_URL": "https://spartacus-dev0.eastus.cloudapp.azure.com:9002", + "API_URL": "https://spartacus-devci767.eastus.cloudapp.azure.com:9002", "OCC_PREFIX": "/occ/v2", "OCC_PREFIX_USER_ENDPOINT": "users", "OCC_PREFIX_ORDER_ENDPOINT": "orders", diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/units.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/units.flaky-e2e-spec.ts similarity index 100% rename from projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/units.e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/units.flaky-e2e-spec.ts diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/users.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/users.flaky-e2e-spec.ts similarity index 100% rename from projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/users.e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/users.flaky-e2e-spec.ts diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/regression/checkout/checkout-as-guest.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/checkout/checkout-as-guest.e2e-spec.ts index 15f55a968c3..09046680443 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/integration/regression/checkout/checkout-as-guest.e2e-spec.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/checkout/checkout-as-guest.e2e-spec.ts @@ -21,41 +21,21 @@ context('Checkout as guest', () => { cy.saveLocalStorage(); }); - describe('Add product and proceed to checkout', () => { + describe('Guest checkout', () => { it('should add product to cart and go to login', () => { checkout.goToCheapProductDetailsPage(); checkout.addCheapProductToCartAndProceedToCheckout(); - }); - it('should show the guest checkout button', () => { cy.get('.register').findByText(/Guest Checkout/i); - }); - }); - describe('Login as guest', () => { - it('should login as guest', () => { guestCheckout.loginAsGuest(); }); - }); - describe('Checkout', () => { - it('should fill in address form', () => { + it('should checkout as guest', () => { checkout.fillAddressFormWithCheapProduct(); - }); - - it('should choose delivery', () => { checkout.verifyDeliveryMethod(); - }); - - it('should fill in payment form', () => { checkout.fillPaymentFormWithCheapProduct(); - }); - - it('should review and place order', () => { checkout.placeOrderWithCheapProduct(); - }); - - it('should display summary page', () => { checkout.verifyOrderConfirmationPageWithCheapProduct(); }); }); @@ -67,15 +47,6 @@ context('Checkout as guest', () => { }); describe('Guest account', () => { - it('should be able to check order in order history', () => { - // hack: visit other page to trigger store -> local storage sync - cy.selectUserMenuOption({ - option: 'Personal Details', - }); - cy.waitForOrderToBePlacedRequest(); - checkout.viewOrderHistoryWithCheapProduct(); - }); - it('should show address in Address Book', () => { cy.selectUserMenuOption({ option: 'Address Book', diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/smoke/checkout-flow.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/checkout/checkout-flow.e2e-spec.ts similarity index 92% rename from projects/storefrontapp-e2e-cypress/cypress/integration/smoke/checkout-flow.e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/regression/checkout/checkout-flow.e2e-spec.ts index b813313b029..0316b11e715 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/integration/smoke/checkout-flow.e2e-spec.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/checkout/checkout-flow.e2e-spec.ts @@ -1,4 +1,4 @@ -import * as checkout from '../../helpers/checkout-flow'; +import * as checkout from '../../../helpers/checkout-flow'; context('Checkout flow', () => { before(() => { diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/regression/checkout/express-checkout.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/checkout/express-checkout.flaky-e2e-spec.ts similarity index 100% rename from projects/storefrontapp-e2e-cypress/cypress/integration/regression/checkout/express-checkout.e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/regression/checkout/express-checkout.flaky-e2e-spec.ts diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/smoke/homepage.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/homepage.e2e-spec.ts similarity index 100% rename from projects/storefrontapp-e2e-cypress/cypress/integration/smoke/homepage.e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/regression/homepage.e2e-spec.ts diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/smoke/login.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/login.e2e-spec.ts similarity index 100% rename from projects/storefrontapp-e2e-cypress/cypress/integration/smoke/login.e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/regression/login.e2e-spec.ts diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/smoke/outlets.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/outlets.e2e-spec.ts similarity index 100% rename from projects/storefrontapp-e2e-cypress/cypress/integration/smoke/outlets.e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/regression/outlets.e2e-spec.ts diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/smoke/product-search.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-search/product-search.e2e-spec.ts similarity index 96% rename from projects/storefrontapp-e2e-cypress/cypress/integration/smoke/product-search.e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-search/product-search.e2e-spec.ts index 6143daf838a..3a977cca5a6 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/integration/smoke/product-search.e2e-spec.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-search/product-search.e2e-spec.ts @@ -1,4 +1,4 @@ -import * as productSearchFlow from '../../helpers/product-search'; +import * as productSearchFlow from '../../../helpers/product-search'; context('Product search', () => { before(() => { diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/smoke/routing.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/routing.e2e-spec.ts similarity index 100% rename from projects/storefrontapp-e2e-cypress/cypress/integration/smoke/routing.e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/regression/routing.e2e-spec.ts diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/smoke/currency.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/site-context/currency/currency.e2e-spec.ts similarity index 90% rename from projects/storefrontapp-e2e-cypress/cypress/integration/smoke/currency.e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/regression/site-context/currency/currency.e2e-spec.ts index 3171da11d9a..ce0626fc3e1 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/integration/smoke/currency.e2e-spec.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/site-context/currency/currency.e2e-spec.ts @@ -1,5 +1,5 @@ -import * as siteContextSelector from '../../helpers/site-context-selector'; -import { switchSiteContext } from '../../support/utils/switch-site-context'; +import * as siteContextSelector from '../../../../helpers/site-context-selector'; +import { switchSiteContext } from '../../../../support/utils/switch-site-context'; context('Currency change', () => { const productPath = siteContextSelector.PRODUCT_PATH_1; diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/regression/site-context/language/language-cart-page.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/site-context/language/language-cart-page.flaky-e2e-spec.ts similarity index 100% rename from projects/storefrontapp-e2e-cypress/cypress/integration/regression/site-context/language/language-cart-page.e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/regression/site-context/language/language-cart-page.flaky-e2e-spec.ts diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/smoke/language.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/site-context/language/language.e2e-spec.ts similarity index 89% rename from projects/storefrontapp-e2e-cypress/cypress/integration/smoke/language.e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/regression/site-context/language/language.e2e-spec.ts index 2a68a19c5f1..4d6b5bda545 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/integration/smoke/language.e2e-spec.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/site-context/language/language.e2e-spec.ts @@ -1,4 +1,4 @@ -import * as siteContextSelector from '../../helpers/site-context-selector'; +import * as siteContextSelector from '../../../../helpers/site-context-selector'; context('Language Switcher', () => { const productPath = siteContextSelector.PRODUCT_PATH_1; diff --git a/projects/storefrontapp-e2e-cypress/cypress/sample-data/b2b-order-approval.ts b/projects/storefrontapp-e2e-cypress/cypress/sample-data/b2b-order-approval.ts index ef7f5644882..d185f740216 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/sample-data/b2b-order-approval.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/sample-data/b2b-order-approval.ts @@ -16,7 +16,7 @@ export const b2bUserAccount: AccountData = { registrationData: { firstName: '', lastName: '', - password: '12341234', + password: 'pw4all', titleCode: 'mr', email: b2bUser.uid, }, @@ -27,7 +27,7 @@ export const b2bApproverAccount: AccountData = { registrationData: { firstName: '', lastName: '', - password: '12341234', + password: 'pw4all', titleCode: 'mr', email: b2bApprover.uid, }, diff --git a/projects/storefrontapp-e2e-cypress/cypress/sample-data/shared-users.ts b/projects/storefrontapp-e2e-cypress/cypress/sample-data/shared-users.ts index 7a4778c9278..50a08110676 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/sample-data/shared-users.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/sample-data/shared-users.ts @@ -16,7 +16,7 @@ export const myCompanyAdminUser: AccountData = { firstName: 'Linda', lastName: 'Wolf', titleCode: '', - password: '12341234', + password: 'pw4all', email: 'linda.wolf@rustic-hw.com', }, }; diff --git a/projects/storefrontapp-e2e-cypress/package.json b/projects/storefrontapp-e2e-cypress/package.json index 68e6623de35..7c813288717 100644 --- a/projects/storefrontapp-e2e-cypress/package.json +++ b/projects/storefrontapp-e2e-cypress/package.json @@ -7,20 +7,15 @@ "scripts": { "cy:open": "cypress open", "cy:run": "cypress run", - "cy:run:ci:1905": "cypress run --config-file cypress.ci.1905.json --record --key $CYPRESS_KEY --tag \"1905,b2c,all\" --spec \"cypress/integration/!(vendor|b2b)/**/*.e2e-spec.ts\"", - "cy:run:ci:2005": "cypress run --config-file cypress.ci.2005.json --record --key $CYPRESS_KEY --tag \"2005,b2c,all,parallel\" --parallel --group $STAGE_NAME --spec \"cypress/integration/!(vendor|b2b)/**/*.e2e-spec.ts\"", + "cy:run:ci:2005": "cypress run --config-file cypress.ci.2005.json --record --key $CYPRESS_KEY --tag \"2005,b2c,all,parallel\" --parallel --group B2C --ci-build-id $TRAVIS_BUILD_ID --spec \"cypress/integration/!(vendor|b2b)/**/*.e2e-spec.ts\"", + "cy:run:ci:2005:b2b": "cypress run --config-file cypress.ci.b2b.json --record --key $CYPRESS_KEY --tag \"2005,b2b,all\" --group B2B --ci-build-id $TRAVIS_BUILD_ID --spec \"cypress/integration/b2b/**/*.e2e-spec.ts\"", "cy:run:ci:flaky:2005": "cypress run --config-file cypress.ci.2005.json --record --key $CYPRESS_KEY --tag \"2005,b2c,parallel,flaky\" --parallel --group flaky --spec \"cypress/integration/!(vendor|b2b)/**/*.flaky-e2e-spec.ts\"", + "cy:run:ci:1905": "cypress run --config-file cypress.ci.1905.json --record --key $CYPRESS_KEY --tag \"1905,b2c,all\" --spec \"cypress/integration/!(vendor|b2b)/**/*.e2e-spec.ts\"", "cy:run:ci:ccv2": "cypress run --config-file cypress.ci.ccv2.json --record --key $CYPRESS_KEY --tag \"ccv2,b2c,all\" --spec \"cypress/integration/!(vendor|b2b)/**/*.e2e-spec.ts\"", - "cy:run:ci:2005:b2b": "cypress run --config-file cypress.ci.b2b.json --record --key $CYPRESS_KEY --tag \"2005,b2b,all\" --parallel --group $STAGE_NAME --spec \"cypress/integration/b2b/**/*.e2e-spec.ts\"", "cy:run:mobile": "cypress run --spec \"cypress/integration/mobile/**/*\"", "cy:run:mobile:ci": "cypress run --config-file cypress.ci.1905.json --spec \"cypress/integration/mobile/**/*\"", "cy:cds:run:ci:2005": "cypress run --config-file cypress.ci.2005.json --record --key $CYPRESS_KEY --tag \"2005,b2c,all-cds\" --spec \"cypress/integration/!(vendor|b2b)/**/*.e2e-spec.ts,cypress/integration/vendor/cds/**/*.e2e-spec.ts\"", "cy:run:regression:ci": "cypress run --config-file cypress.ci.1905.json --spec \"cypress/integration/regression/**/*\"", - "cy:run:smoke": "cypress run --spec \"cypress/integration/smoke/**/*\"", - "cy:run:smoke:ci:1905": "cypress run --config-file cypress.ci.1905.json --record --key $CYPRESS_KEY --tag \"1905,b2c,smoke\" --spec \"cypress/integration/smoke/**/*\"", - "cy:run:smoke:ci:2005": "cypress run --config-file cypress.ci.2005.json --record --key $CYPRESS_KEY --tag \"2005,b2c,smoke\" --spec \"cypress/integration/smoke/**/*\"", - "cy:run:smoke:ci:2005:b2b": "cypress run --config-file cypress.ci.b2b.json --record --key $CYPRESS_KEY --tag \"2005,b2b,smoke\" --spec \"cypress/integration/b2b/smoke/**/*\"", - "cy:run:smoke:ci:ccv2": "cypress run --config-file cypress.ci.ccv2.json --record --key $CYPRESS_KEY --tag \"ccv2,b2c,smoke\" --spec \"cypress/integration/smoke/**/*\"", "cy:run:b2b": "cypress run --config-file cypress.ci.b2b.json --spec \"cypress/integration/b2b/**/*\"", "cy:run:b2b:ci": "cypress run --config-file cypress.ci.b2b.json --record --key $CYPRESS_KEY --tag \"2005,b2b,all,parallel\" --spec \"cypress/integration/b2b/**/*\"", "cy:open:b2b": "cypress open --config-file cypress.ci.b2b.json --config testFiles=**/b2b/**/*" diff --git a/projects/storefrontapp/src/environments/environment.prod.ts b/projects/storefrontapp/src/environments/environment.prod.ts index 1e0031d1f07..c8c03effe2d 100644 --- a/projects/storefrontapp/src/environments/environment.prod.ts +++ b/projects/storefrontapp/src/environments/environment.prod.ts @@ -4,7 +4,7 @@ export const environment: Environment = { production: true, occBaseUrl: build.process.env.SPARTACUS_BASE_URL ?? - 'https://spartacus-dev0.eastus.cloudapp.azure.com:9002', + 'https://spartacus-devci767.eastus.cloudapp.azure.com:9002', occApiPrefix: build.process.env.SPARTACUS_API_PREFIX ?? '/occ/v2/', cds: build.process.env.SPARTACUS_CDS, b2b: build.process.env.SPARTACUS_B2B, diff --git a/projects/storefrontapp/src/environments/environment.ts b/projects/storefrontapp/src/environments/environment.ts index 064f7c55472..a26069cf062 100644 --- a/projects/storefrontapp/src/environments/environment.ts +++ b/projects/storefrontapp/src/environments/environment.ts @@ -9,8 +9,8 @@ export const environment: Environment = { production: false, occBaseUrl: build.process.env.SPARTACUS_BASE_URL ?? - 'https://spartacus-dev0.eastus.cloudapp.azure.com:9002', - // 'https://spartacus-dev3.eastus.cloudapp.azure.com:9002', + 'https://spartacus-devci767.eastus.cloudapp.azure.com:9002', + // 'https://spartacus-dev0.eastus.cloudapp.azure.com:9002', occApiPrefix: build.process.env.SPARTACUS_API_PREFIX ?? '/occ/v2/', cds: build.process.env.SPARTACUS_CDS ?? false, b2b: build.process.env.SPARTACUS_B2B ?? false, From 762908c94351e1a4fdf818fc6cff8c2c771a6b24 Mon Sep 17 00:00:00 2001 From: Caine Rotherham Date: Wed, 20 Jan 2021 08:26:26 +0100 Subject: [PATCH 09/30] fix: Disable create button when parent unit is disabled (#10688) Closes: #10059 --- .../children/unit-children.component.html | 7 +++++- .../children/unit-children.component.spec.ts | 7 ++++++ .../links/children/unit-children.component.ts | 15 ++++++++++++- .../links/children/unit-children.module.ts | 3 ++- .../unit-cost-centers.component.html | 7 +++++- .../unit-cost-centers.component.spec.ts | 7 ++++++ .../unit-cost-centers.component.ts | 15 ++++++++++++- .../cost-centers/unit-cost-centers.module.ts | 3 ++- .../users/list/unit-user-list.component.html | 7 +++++- .../list/unit-user-list.component.spec.ts | 7 ++++++ .../users/list/unit-user-list.component.ts | 13 +++++++++++ .../constructor-deprecations.ts | 22 +++++++++++++++++++ .../data/unit-children.component.migration.ts | 20 +++++++++++++++++ .../unit-cost-centers.component.migration.ts | 20 +++++++++++++++++ .../unit-user-list.component.migration.ts | 20 +++++++++++++++++ projects/schematics/src/shared/constants.ts | 9 ++++++++ 16 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 projects/schematics/src/migrations/4_0/constructor-deprecations/constructor-deprecations.ts create mode 100644 projects/schematics/src/migrations/4_0/constructor-deprecations/data/unit-children.component.migration.ts create mode 100644 projects/schematics/src/migrations/4_0/constructor-deprecations/data/unit-cost-centers.component.migration.ts create mode 100644 projects/schematics/src/migrations/4_0/constructor-deprecations/data/unit-user-list.component.migration.ts diff --git a/feature-libs/organization/administration/components/unit/links/children/unit-children.component.html b/feature-libs/organization/administration/components/unit/links/children/unit-children.component.html index fdca348f4e1..fdbfb3cde80 100644 --- a/feature-libs/organization/administration/components/unit/links/children/unit-children.component.html +++ b/feature-libs/organization/administration/components/unit/links/children/unit-children.component.html @@ -1,5 +1,10 @@ - + {{ 'organization.create' | cxTranslate }} diff --git a/feature-libs/organization/administration/components/unit/links/children/unit-children.component.spec.ts b/feature-libs/organization/administration/components/unit/links/children/unit-children.component.spec.ts index 27c40593f17..f4d3edf75dd 100644 --- a/feature-libs/organization/administration/components/unit/links/children/unit-children.component.spec.ts +++ b/feature-libs/organization/administration/components/unit/links/children/unit-children.component.spec.ts @@ -2,11 +2,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { I18nTestingModule } from '@spartacus/core'; import { UrlTestingModule } from 'projects/core/src/routing/configurable-routes/url-translation/testing/url-testing.module'; import { SubListTestingModule } from '../../../shared/sub-list/sub-list.testing.module'; +import { CurrentUnitService } from '../../services/current-unit.service'; import { UnitChildrenComponent } from './unit-children.component'; import { UnitChildrenService } from './unit-children.service'; class MockUnitChildrenService {} +class MockCurrentUnitService implements Partial {} + describe('UnitChildrenComponent', () => { let component: UnitChildrenComponent; let fixture: ComponentFixture; @@ -19,6 +22,10 @@ describe('UnitChildrenComponent', () => { provide: UnitChildrenService, useClass: MockUnitChildrenService, }, + { + provide: CurrentUnitService, + useClass: MockCurrentUnitService, + }, ], declarations: [UnitChildrenComponent], }).compileComponents(); diff --git a/feature-libs/organization/administration/components/unit/links/children/unit-children.component.ts b/feature-libs/organization/administration/components/unit/links/children/unit-children.component.ts index 770c1069459..8510b3d2f7c 100644 --- a/feature-libs/organization/administration/components/unit/links/children/unit-children.component.ts +++ b/feature-libs/organization/administration/components/unit/links/children/unit-children.component.ts @@ -1,5 +1,8 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { B2BUnit } from '@spartacus/core'; +import { Observable, of } from 'rxjs'; import { ListService } from '../../../shared/list/list.service'; +import { CurrentUnitService } from '../../services/current-unit.service'; import { UnitChildrenService } from './unit-children.service'; @Component({ @@ -14,4 +17,14 @@ import { UnitChildrenService } from './unit-children.service'; }, ], }) -export class UnitChildrenComponent {} +export class UnitChildrenComponent { + unit$: Observable = this.currentUnitService + ? this.currentUnitService.item$ + : of({ active: true }); + + /** + * @deprecated since 3.0.10 + * Include CurrentUnitService in constructor for bugfix #10688. + */ + constructor(protected currentUnitService?: CurrentUnitService) {} +} diff --git a/feature-libs/organization/administration/components/unit/links/children/unit-children.module.ts b/feature-libs/organization/administration/components/unit/links/children/unit-children.module.ts index 0fd3e97dbe9..ae9daafcb97 100644 --- a/feature-libs/organization/administration/components/unit/links/children/unit-children.module.ts +++ b/feature-libs/organization/administration/components/unit/links/children/unit-children.module.ts @@ -1,3 +1,4 @@ +import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { I18nModule } from '@spartacus/core'; @@ -6,7 +7,7 @@ import { SubListModule } from '../../../shared/sub-list/sub-list.module'; import { UnitChildrenComponent } from './unit-children.component'; @NgModule({ - imports: [ListModule, I18nModule, RouterModule, SubListModule], + imports: [ListModule, I18nModule, RouterModule, SubListModule, CommonModule], declarations: [UnitChildrenComponent], }) export class UnitChildrenModule {} diff --git a/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.component.html b/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.component.html index fdca348f4e1..fdbfb3cde80 100644 --- a/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.component.html +++ b/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.component.html @@ -1,5 +1,10 @@ - + {{ 'organization.create' | cxTranslate }} diff --git a/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.component.spec.ts b/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.component.spec.ts index 39c0f9aa9d1..8c279fa6694 100644 --- a/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.component.spec.ts +++ b/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.component.spec.ts @@ -2,11 +2,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { I18nTestingModule } from '@spartacus/core'; import { UrlTestingModule } from 'projects/core/src/routing/configurable-routes/url-translation/testing/url-testing.module'; import { SubListTestingModule } from '../../../shared/sub-list/sub-list.testing.module'; +import { CurrentUnitService } from '../../services/current-unit.service'; import { UnitCostCenterListComponent } from './unit-cost-centers.component'; import { UnitCostCenterListService } from './unit-cost-centers.service'; class MockUnitCostCenterListService {} +class MockCurrentUnitService implements Partial {} + describe('UnitCostCenterListComponent', () => { let component: UnitCostCenterListComponent; let fixture: ComponentFixture; @@ -19,6 +22,10 @@ describe('UnitCostCenterListComponent', () => { provide: UnitCostCenterListService, useClass: MockUnitCostCenterListService, }, + { + provide: CurrentUnitService, + useClass: MockCurrentUnitService, + }, ], declarations: [UnitCostCenterListComponent], }).compileComponents(); diff --git a/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.component.ts b/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.component.ts index 9eb4f2b21de..8a3e6761893 100644 --- a/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.component.ts +++ b/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.component.ts @@ -1,5 +1,8 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { B2BUnit } from '@spartacus/core'; +import { Observable, of } from 'rxjs'; import { ListService } from '../../../shared/list/list.service'; +import { CurrentUnitService } from '../../services/current-unit.service'; import { UnitCostCenterListService } from './unit-cost-centers.service'; @Component({ @@ -14,4 +17,14 @@ import { UnitCostCenterListService } from './unit-cost-centers.service'; }, ], }) -export class UnitCostCenterListComponent {} +export class UnitCostCenterListComponent { + unit$: Observable = this.currentUnitService + ? this.currentUnitService.item$ + : of({ active: true }); + + /** + * @deprecated since 3.0.10 + * Include CurrentUnitService in constructor for bugfix #10688. + */ + constructor(protected currentUnitService?: CurrentUnitService) {} +} diff --git a/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.module.ts b/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.module.ts index cd64e2db54f..544fb19f45e 100644 --- a/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.module.ts +++ b/feature-libs/organization/administration/components/unit/links/cost-centers/unit-cost-centers.module.ts @@ -1,3 +1,4 @@ +import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { I18nModule } from '@spartacus/core'; @@ -5,7 +6,7 @@ import { SubListModule } from '../../../shared/sub-list/sub-list.module'; import { UnitCostCenterListComponent } from './unit-cost-centers.component'; @NgModule({ - imports: [I18nModule, RouterModule, SubListModule], + imports: [I18nModule, RouterModule, SubListModule, CommonModule], declarations: [UnitCostCenterListComponent], }) export class UnitCostCenterListModule {} diff --git a/feature-libs/organization/administration/components/unit/links/users/list/unit-user-list.component.html b/feature-libs/organization/administration/components/unit/links/users/list/unit-user-list.component.html index bf3fc075303..0483dc53eb6 100644 --- a/feature-libs/organization/administration/components/unit/links/users/list/unit-user-list.component.html +++ b/feature-libs/organization/administration/components/unit/links/users/list/unit-user-list.component.html @@ -1,5 +1,10 @@ - + {{ 'organization.create' | cxTranslate }} diff --git a/feature-libs/organization/administration/components/unit/links/users/list/unit-user-list.component.spec.ts b/feature-libs/organization/administration/components/unit/links/users/list/unit-user-list.component.spec.ts index 2d21ad792e6..67271eecd30 100644 --- a/feature-libs/organization/administration/components/unit/links/users/list/unit-user-list.component.spec.ts +++ b/feature-libs/organization/administration/components/unit/links/users/list/unit-user-list.component.spec.ts @@ -2,11 +2,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { I18nTestingModule } from '@spartacus/core'; import { SubListTestingModule } from 'feature-libs/organization/administration/components/shared/sub-list/sub-list.testing.module'; import { UrlTestingModule } from 'projects/core/src/routing/configurable-routes/url-translation/testing/url-testing.module'; +import { CurrentUnitService } from '../../../services/current-unit.service'; import { UnitUserListService } from '../services/unit-user-list.service'; import { UnitUserListComponent } from './unit-user-list.component'; class MockUnitUserListService {} +class MockCurrentUnitService implements Partial {} + describe('UnitUserListComponent', () => { let component: UnitUserListComponent; let fixture: ComponentFixture; @@ -19,6 +22,10 @@ describe('UnitUserListComponent', () => { provide: UnitUserListService, useClass: MockUnitUserListService, }, + { + provide: CurrentUnitService, + useClass: MockCurrentUnitService, + }, ], declarations: [UnitUserListComponent], }).compileComponents(); diff --git a/feature-libs/organization/administration/components/unit/links/users/list/unit-user-list.component.ts b/feature-libs/organization/administration/components/unit/links/users/list/unit-user-list.component.ts index 04c89c5c354..4cfe912e9f1 100644 --- a/feature-libs/organization/administration/components/unit/links/users/list/unit-user-list.component.ts +++ b/feature-libs/organization/administration/components/unit/links/users/list/unit-user-list.component.ts @@ -1,6 +1,9 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { B2BUnit } from '@spartacus/core'; +import { Observable, of } from 'rxjs'; import { ROUTE_PARAMS } from '../../../../constants'; import { ListService } from '../../../../shared/list/list.service'; +import { CurrentUnitService } from '../../../services/current-unit.service'; import { UnitUserListService } from '../services/unit-user-list.service'; @Component({ @@ -17,4 +20,14 @@ import { UnitUserListService } from '../services/unit-user-list.service'; }) export class UnitUserListComponent { routerKey = ROUTE_PARAMS.userCode; + + unit$: Observable = this.currentUnitService + ? this.currentUnitService.item$ + : of({ active: true }); + + /** + * @deprecated since 3.0.10 + * Include CurrentUnitService in constructor for bugfix #10688. + */ + constructor(protected currentUnitService?: CurrentUnitService) {} } diff --git a/projects/schematics/src/migrations/4_0/constructor-deprecations/constructor-deprecations.ts b/projects/schematics/src/migrations/4_0/constructor-deprecations/constructor-deprecations.ts new file mode 100644 index 00000000000..11421246069 --- /dev/null +++ b/projects/schematics/src/migrations/4_0/constructor-deprecations/constructor-deprecations.ts @@ -0,0 +1,22 @@ +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { ConstructorDeprecation } from '../../../shared/utils/file-utils'; +import { migrateConstructorDeprecation } from '../../mechanism/constructor-deprecations/constructor-deprecations'; +import { UNIT_CHILDREN_COMPONENT_MIGRATION } from './data/unit-children.component.migration'; +import { UNIT_COST_CENTERS_COMPONENT_MIGRATION } from './data/unit-cost-centers.component.migration'; +import { UNIT_USER_LIST_COMPONENT_MIGRATION } from './data/unit-user-list.component.migration'; + +export const CONSTRUCTOR_DEPRECATION_DATA: ConstructorDeprecation[] = [ + UNIT_CHILDREN_COMPONENT_MIGRATION, + UNIT_COST_CENTERS_COMPONENT_MIGRATION, + UNIT_USER_LIST_COMPONENT_MIGRATION, +]; + +export function migrate(): Rule { + return (tree: Tree, context: SchematicContext) => { + return migrateConstructorDeprecation( + tree, + context, + CONSTRUCTOR_DEPRECATION_DATA + ); + }; +} diff --git a/projects/schematics/src/migrations/4_0/constructor-deprecations/data/unit-children.component.migration.ts b/projects/schematics/src/migrations/4_0/constructor-deprecations/data/unit-children.component.migration.ts new file mode 100644 index 00000000000..52130def77a --- /dev/null +++ b/projects/schematics/src/migrations/4_0/constructor-deprecations/data/unit-children.component.migration.ts @@ -0,0 +1,20 @@ +import { + CURRENT_UNIT_SERVICE, + SPARTACUS_ORGANIZATION_ADMINISTRATION_COMPONENTS, + SPARTACUS_ORGANIZATION_ADMINISTRATION_CORE, + UNIT_CHILDREN_COMPONENT, +} from '../../../../shared/constants'; +import { ConstructorDeprecation } from '../../../../shared/utils/file-utils'; + +export const UNIT_CHILDREN_COMPONENT_MIGRATION: ConstructorDeprecation = { + // feature-libs\organization\administration\components\unit\links\children\unit-children.component.ts + class: UNIT_CHILDREN_COMPONENT, + importPath: SPARTACUS_ORGANIZATION_ADMINISTRATION_COMPONENTS, + deprecatedParams: [], + addParams: [ + { + className: CURRENT_UNIT_SERVICE, + importPath: SPARTACUS_ORGANIZATION_ADMINISTRATION_CORE, + }, + ], +}; diff --git a/projects/schematics/src/migrations/4_0/constructor-deprecations/data/unit-cost-centers.component.migration.ts b/projects/schematics/src/migrations/4_0/constructor-deprecations/data/unit-cost-centers.component.migration.ts new file mode 100644 index 00000000000..9802f8d7fdc --- /dev/null +++ b/projects/schematics/src/migrations/4_0/constructor-deprecations/data/unit-cost-centers.component.migration.ts @@ -0,0 +1,20 @@ +import { + CURRENT_UNIT_SERVICE, + SPARTACUS_ORGANIZATION_ADMINISTRATION_COMPONENTS, + SPARTACUS_ORGANIZATION_ADMINISTRATION_CORE, + UNIT_COST_CENTER_LIST_COMPONENT, +} from '../../../../shared/constants'; +import { ConstructorDeprecation } from '../../../../shared/utils/file-utils'; + +export const UNIT_COST_CENTERS_COMPONENT_MIGRATION: ConstructorDeprecation = { + // feature-libs\organization\administration\components\unit\links\cost-centers\unit-cost-centers.component.ts + class: UNIT_COST_CENTER_LIST_COMPONENT, + importPath: SPARTACUS_ORGANIZATION_ADMINISTRATION_COMPONENTS, + deprecatedParams: [], + addParams: [ + { + className: CURRENT_UNIT_SERVICE, + importPath: SPARTACUS_ORGANIZATION_ADMINISTRATION_CORE, + }, + ], +}; diff --git a/projects/schematics/src/migrations/4_0/constructor-deprecations/data/unit-user-list.component.migration.ts b/projects/schematics/src/migrations/4_0/constructor-deprecations/data/unit-user-list.component.migration.ts new file mode 100644 index 00000000000..58ccfe6109a --- /dev/null +++ b/projects/schematics/src/migrations/4_0/constructor-deprecations/data/unit-user-list.component.migration.ts @@ -0,0 +1,20 @@ +import { + CURRENT_UNIT_SERVICE, + SPARTACUS_ORGANIZATION_ADMINISTRATION_COMPONENTS, + SPARTACUS_ORGANIZATION_ADMINISTRATION_CORE, + UNIT_USER_LIST_COMPONENT, +} from '../../../../shared/constants'; +import { ConstructorDeprecation } from '../../../../shared/utils/file-utils'; + +export const UNIT_USER_LIST_COMPONENT_MIGRATION: ConstructorDeprecation = { + // feature-libs\organization\administration\components\unit\links\users\list\unit-user-list.component.ts + class: UNIT_USER_LIST_COMPONENT, + importPath: SPARTACUS_ORGANIZATION_ADMINISTRATION_COMPONENTS, + deprecatedParams: [], + addParams: [ + { + className: CURRENT_UNIT_SERVICE, + importPath: SPARTACUS_ORGANIZATION_ADMINISTRATION_CORE, + }, + ], +}; diff --git a/projects/schematics/src/shared/constants.ts b/projects/schematics/src/shared/constants.ts index b05877eb294..640cd2dea90 100644 --- a/projects/schematics/src/shared/constants.ts +++ b/projects/schematics/src/shared/constants.ts @@ -36,6 +36,11 @@ export const NGRX_STORE = '@ngrx/store'; export const NGRX_EFFECTS = '@ngrx/effects'; export const NGUNIVERSAL_EXPRESS_ENGINE = '@nguniversal/express-engine'; + +export const SPARTACUS_ORGANIZATION_ADMINISTRATION_CORE = + '@spartacus/organization/administration/core'; +export const SPARTACUS_ORGANIZATION_ADMINISTRATION_COMPONENTS = + '@spartacus/organization/administration/components'; /***** Imports end *****/ /***** Classes start *****/ @@ -319,6 +324,10 @@ export const ANONYMOUS_CONSENT_TEMPLATES_CONNECTOR = export const VIEW_COMPONENT = 'ViewComponent'; export const SPLIT_VIEW_COMPONENT = 'SplitViewComponent'; export const OCC_CMS_COMPONENT_ADAPTER = 'OccCmsComponentAdapter'; +export const CURRENT_UNIT_SERVICE = 'CurrentUnitService'; +export const UNIT_CHILDREN_COMPONENT = 'UnitChildrenComponent'; +export const UNIT_COST_CENTER_LIST_COMPONENT = 'UnitCostCenterListComponent'; +export const UNIT_USER_LIST_COMPONENT = 'UnitUserListComponent'; /***** Classes end *****/ From 0a6a402c1c24db762bf5d922c45238eba57687dd Mon Sep 17 00:00:00 2001 From: Marcin Lasak Date: Wed, 20 Jan 2021 14:11:03 +0100 Subject: [PATCH 10/30] chore: Improve testing setup for schematics (#10768) --- package.json | 1 + projects/schematics/README.md | 54 +++------- scripts/publish-schematics-verdaccio.sh | 91 ---------------- tools/schematics/testing.ts | 137 ++++++++++++++++++++++++ yarn.lock | 9 +- 5 files changed, 163 insertions(+), 129 deletions(-) delete mode 100755 scripts/publish-schematics-verdaccio.sh create mode 100644 tools/schematics/testing.ts diff --git a/package.json b/package.json index baa8f38f721..2faa3bcc9c9 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,7 @@ "core-js": "^3.2.1", "cross-env": "^7.0.0", "ejs": "^2.6.2", + "enquirer": "^2.3.6", "faker": "^4.1.0", "gh-got": "^8.0.1", "gh-pages": "^2.1.1", diff --git a/projects/schematics/README.md b/projects/schematics/README.md index 69d954547e4..a7b532eb52c 100644 --- a/projects/schematics/README.md +++ b/projects/schematics/README.md @@ -8,8 +8,6 @@ To see the documentation on how to use schematics from a customers perspective, Install angular schematics globally: `npm install -g @angular-devkit/schematics-cli`. Make sure that Angular CLI is up to date: `npm install -g @angular/cli@latest` -Navigate to `$ cd projects/schematics` and install the dependencies using `$ yarn install`. - ## Testing schematics ### Unit testing @@ -31,51 +29,33 @@ The following points provide guidance on how to achieve that. ### Integration testing -The best way to test an unpublished schematic is to publish it to a local npm registry. For more, see [developing update schematics](#Developing-update-schematics) - -## Developing update schematics - -### Verdaccio setup - -To setup a local npm registry, we're going to use [verdaccio](https://github.com/verdaccio/verdaccio). To set it up, do the following: - -- install it: `npm install --global verdaccio` -- run it in a new terminal tab / window: `verdaccio` -- create an npm user: `npm adduser --registry http://localhost:4873`. This is only needed when setting up _verdaccio_ for the first time. - -### Using verdaccio - -Create a new angular project: +The best way to test an unpublished schematic is to publish it to a local npm registry. For more, see [developing schematics](#Developing-schematics) -- `ng new spartacus-schematics-test` and `cd spartacus-schematics-test` -- add Spartacus by running e.g. `ng add @spartacus/schematics@ --baseUrl https://spartacus-demo.eastus.cloudapp.azure.com:8443/ --baseSite electronics-spa`. Note the `` after `ng add @spartacus/schematics`. This should be lower than the one you're going to publish. E.g. if developing schematics for Spartacus 3.0, then you should install Spartacus 2.0. -- create `.npmrc` in the root of the project and paste the following content to it: `@spartacus:registry=http://localhost:4873` to point to the local npm server only for the `@spartacus` scoped packages. From this moment on, `@spartacus` scoped packages will use the local npm registry. -- commit the changes, if any. +## Developing schematics -You can now run any Spartacus schematics related command, e.g. `ng add @spartacus/schematics` or `ng update @spartacus/schematics`, and angular will pull the Spartacus schematics lib from _verdaccio_ instead from the public _npm_ registry. +### Preparing setup -The next step is to publish libraries to _verdaccio_. +- Install verdaccio `npm i -g verdaccio` (only for the first time) +- Create new angular project `ng new schematics-test --style=scss` +- Run verdaccio script `ts-node ./tools/schematics/testing.ts` ### Publishing to verdaccio -The simplest way to publish Spartacus libraries to _verdaccio_ is to use `scripts/publish-schematics-verdaccio.sh` script. - -> Before running the script, make sure _verdaccio_ is running: `$ verdaccio`. - -To use it, just run `./publish-schematics-verdaccio.sh`. This will build _all_ the relevant spartacus libs and publish them to _verdaccio_. +- before you publish for the first time make sure you have builded libs or run `build all libs` +- select option `publish` from the verdaccio script (it will bump package patch version and publish to verdaccio) +- do changes, rebuild changed libraries and publish once again (every publish will bump to even higher version) -> NOTE: if _verdaccio_ refuses to publish libraries, and shows an error that says that the lib is already published with the same version, the quickest way around this seems to be [this](https://github.com/verdaccio/verdaccio/issues/1203#issuecomment-457361429) - open `nano ~/.config/verdaccio/config.yaml` and under `packages: '@*/*':` sections, comment out the `proxy: npmjs` line. After doing this, you should be able to publish the packages. +### Workflow for testing schematics -#### Iterative development +- run schematics you want to test (to revert schematics changes `git reset --hard HEAD && rm -rf node_modules && npm i`) +- try until everything is perfect -As building all the Spartacus libraries every time you make a change to the schematics project takes time, it's not very convenient for iterative development. For this reason, you can run the script with `skip` flag - `./publish-schematics-verdaccio.sh skip`. This will skip building of all Spartacus libraries except the schematics, and it will unpublish and publish all the libraries again to _verdaccio_. +### Workflow for testing migrations -When doing iterative development of the update schematics, it's for the best to do the following before testing the changes: - -- in the testing project: - - revert the `package.json` and `yarn.lock` changes - - delete the old `node_modules` folder and install the dependencies again: `rm -rf node_modules/ && yarn` - - run the `ng update @spartacus/schematics` command +- add Spartacus by running e.g. `ng add @spartacus/schematics@ --baseUrl https://spartacus-demo.eastus.cloudapp.azure.com:8443/ --baseSite electronics-spa`. Note the `` after `ng add @spartacus/schematics`. This should be lower than the one you're going to publish. E.g. if developing schematics for Spartacus 3.0, then you should install Spartacus 2.0. +- commit the changes, if any. +- run schematics you want to test (to revert schematics changes `git reset --hard HEAD && rm -rf node_modules && npm i`) +- try until everything is perfect ## Update schematics diff --git a/scripts/publish-schematics-verdaccio.sh b/scripts/publish-schematics-verdaccio.sh deleted file mode 100755 index e22014c90ea..00000000000 --- a/scripts/publish-schematics-verdaccio.sh +++ /dev/null @@ -1,91 +0,0 @@ -########################################################### -# This script builds the relevant spartacus libraries -# and publishes them to the local npm registry. -# -# The building part can be skipped by providing the -# `skip` argument when calling the script: -# `./publish-schematics-verdaccio.sh skip` -# -# Building and publishing dev-schematics require `dev` param: -# `./publish-schematics-verdaccio.sh dev` -########################################################### - -unpublish () { - echo "unpublishing "$1"" - npm unpublish @spartacus/"$1" --registry http://localhost:4873 --force -} - -publish () { - echo "publishing "$1"" - npm publish --registry http://localhost:4873 -} - -doItFor () { - cd "$1" - - if [[ "$1" == "storefrontlib" ]]; then - unpublish "storefront" - else - unpublish "$1" - fi - - publish "$1" - cd .. -} - -SKIP_BUILD="$1" -cd ../ - -if [[ -z "$SKIP_BUILD" ]]; then - rm -rf dist -fi - -cd projects/schematics -yarn build -cd ../../ -cd feature-libs/organization -yarn build:schematics -cd ../../ -cd feature-libs/storefinder -yarn build:schematics -cd ../../ - -if [[ -z "$SKIP_BUILD" ]]; then - yarn build:libs -else - # this also builds the organization schematics - yarn build:organization - # this also builds the storefinder schematics - yarn build:storefinder -fi -cd dist - -if [[ -z "$SKIP_BUILD" ]]; then - cd ../projects/storefrontstyles - ng build - cd ../../dist -fi - -doItFor "assets" -doItFor "core" -doItFor "storefrontlib" -doItFor "cds" -doItFor "organization" -doItFor "storefinder" -doItFor "setup" - -cd ../projects/storefrontstyles -unpublish "styles" && publish "styles" -cd ../../dist - -cd ../projects/schematics -unpublish "schematics" && publish "schematics" -cd ../../dist - -if [[ "$1" == "dev" ]] || [[ "$2" == "dev" ]]; then - cd ../projects/dev-schematics - unpublish "dev-schematics" - yarn && yarn build - publish "dev-schematics" - cd ../../dist -fi diff --git a/tools/schematics/testing.ts b/tools/schematics/testing.ts new file mode 100644 index 00000000000..36d15e1517d --- /dev/null +++ b/tools/schematics/testing.ts @@ -0,0 +1,137 @@ +import { ChildProcess, exec, execSync } from 'child_process'; +import { prompt } from 'enquirer'; +import fs from 'fs'; +import glob from 'glob'; +import path from 'path'; +import semver from 'semver'; + +let currentVersion; + +function startVerdaccio(): ChildProcess { + console.log('Starting verdaccio'); + execSync('rm -rf ./scripts/install/storage'); + const res = exec('verdaccio --config ./scripts/install/config.yaml'); + console.log('Pointing npm to verdaccio'); + execSync(`npm config set @spartacus:registry http://localhost:4873/`); + return res; +} + +function beforeExit() { + console.log('Setting npm back to npmjs.org'); + execSync(`npm config set @spartacus:registry https://registry.npmjs.org/`); + if (verdaccioProcess) { + try { + console.log('Killing verdaccio'); + verdaccioProcess.kill(); + } catch {} + } +} + +function publishLibs() { + if (!currentVersion) { + currentVersion = semver.parse( + JSON.parse(fs.readFileSync('projects/core/package.json', 'utf-8')).version + ); + } + + // Bump version to publish + semver.inc(currentVersion, 'patch'); + // Packages released from it's source directory + const files = [ + 'projects/storefrontstyles/package.json', + 'projects/schematics/package.json', + ]; + const distFiles = glob.sync(`dist/!(node_modules)/package.json`); + + [...files, ...distFiles].forEach((packagePath) => { + // Update version in package + const content = JSON.parse(fs.readFileSync(packagePath, 'utf-8')); + content.version = currentVersion.version; + fs.writeFileSync(packagePath, JSON.stringify(content, undefined, 2)); + + // Publish package + const dir = path.dirname(packagePath); + console.log(`\nPublishing ${content.name}`); + execSync( + `yarn publish --cwd ${dir} --new-version ${currentVersion.version} --registry=http://localhost:4873/ --no-git-tag-version`, + { stdio: 'inherit' } + ); + }); +} + +function buildLibs() { + execSync('yarn build:libs', { stdio: 'inherit' }); +} + +function buildSchematics() { + execSync('yarn build:schematics', { stdio: 'inherit' }); +} + +async function executeCommand( + command: 'publish' | 'build projects/schematics' | 'build all libs' +): Promise { + switch (command) { + case 'publish': + publishLibs(); + break; + case 'build projects/schematics': + buildSchematics(); + break; + case 'build all libs': + buildLibs(); + break; + default: + const cmd: never = command; + throw new Error(`Command ${cmd} not covered!`); + } +} + +let verdaccioProcess: ChildProcess | undefined; + +async function program() { + verdaccioProcess = startVerdaccio(); + try { + // Give time for verdaccio to boot up + console.log('Waiting for verdaccio to boot...'); + execSync(`sleep 30`); + + while (true) { + const choices = [ + 'publish', + 'build projects/schematics', + 'build all libs', + 'exit', + ]; + const response: { command: typeof choices[number] } = await prompt({ + name: 'command', + type: 'select', + message: 'What do you want to do next?', + choices: [...choices], + }); + + if (response.command === 'exit') { + beforeExit(); + process.exit(); + } else { + executeCommand(response.command); + } + } + } catch (e) { + console.log(e); + beforeExit(); + process.exit(); + } +} + +program(); + +// Handle killing the script +process.once('SIGINT', function () { + beforeExit(); + process.exit(); +}); + +process.once('SIGTERM', function () { + beforeExit(); + process.exit(); +}); diff --git a/yarn.lock b/yarn.lock index 4dda1d76c7a..8d996846dad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2913,7 +2913,7 @@ ansi-align@^3.0.0: dependencies: string-width "^3.0.0" -ansi-colors@4.1.1: +ansi-colors@4.1.1, ansi-colors@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== @@ -5696,6 +5696,13 @@ enhanced-resolve@4.3.0, enhanced-resolve@^4.0.0, enhanced-resolve@^4.1.1, enhanc memory-fs "^0.5.0" tapable "^1.0.0" +enquirer@^2.3.6: + version "2.3.6" + resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" + integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== + dependencies: + ansi-colors "^4.1.1" + ent@~2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d" From 2a615c783407cc476c1097227f03fee9a75e38bc Mon Sep 17 00:00:00 2001 From: Krzysztof Platis Date: Wed, 20 Jan 2021 15:53:14 +0100 Subject: [PATCH 11/30] fix: avoid sharing i18next instance in between SSR requests (#10752) The i18next global instance as shared in between all SSR requests. This was buggy, i.e. when 2 requests are made at the same time for different acitive langauges. Now we fix it, by creating a fresh instance for each SSR request (for each app bootstrap). close #8100 --- .../core/src/i18n/i18next/i18next-init.ts | 34 ++++++++++++++----- .../core/src/i18n/i18next/i18next-instance.ts | 16 +++++++++ .../src/i18n/i18next/i18next-providers.ts | 5 ++- .../i18next-translation.service.spec.ts | 10 ++++-- .../i18next/i18next-translation.service.ts | 27 ++++++++------- 5 files changed, 69 insertions(+), 23 deletions(-) create mode 100644 projects/core/src/i18n/i18next/i18next-instance.ts diff --git a/projects/core/src/i18n/i18next/i18next-init.ts b/projects/core/src/i18n/i18next/i18next-init.ts index 58255492288..0d800d30033 100644 --- a/projects/core/src/i18n/i18next/i18next-init.ts +++ b/projects/core/src/i18n/i18next/i18next-init.ts @@ -1,15 +1,19 @@ import { HttpClient } from '@angular/common/http'; -import i18next, { InitOptions } from 'i18next'; +import { Injectable, OnDestroy } from '@angular/core'; +import { i18n, InitOptions } from 'i18next'; import i18nextXhrBackend from 'i18next-xhr-backend'; +import { Subscription } from 'rxjs'; import { ConfigInitializerService } from '../../config/config-initializer/config-initializer.service'; import { LanguageService } from '../../site-context/facade/language.service'; import { TranslationResources } from '../translation-resources'; export function i18nextInit( + i18next: i18n, configInit: ConfigInitializerService, languageService: LanguageService, httpClient: HttpClient, - serverRequestOrigin: string + serverRequestOrigin: string, + siteContextI18nextSynchronizer: SiteContextI18nextSynchronizer ): () => Promise { return () => configInit.getStableConfig('i18n').then((config) => { @@ -37,13 +41,16 @@ export function i18nextInit( return i18next.init(i18nextConfig, () => { // Don't use i18next's 'resources' config key for adding static translations, // because it will disable loading chunks from backend. We add resources here, in the init's callback. - i18nextAddTranslations(config.i18n.resources); - syncI18nextWithSiteContext(languageService); + i18nextAddTranslations(i18next, config.i18n.resources); + siteContextI18nextSynchronizer.init(i18next, languageService); }); }); } -export function i18nextAddTranslations(resources: TranslationResources = {}) { +export function i18nextAddTranslations( + i18next: i18n, + resources: TranslationResources = {} +) { Object.keys(resources).forEach((lang) => { Object.keys(resources[lang]).forEach((chunkName) => { i18next.addResourceBundle( @@ -57,9 +64,20 @@ export function i18nextAddTranslations(resources: TranslationResources = {}) { }); } -export function syncI18nextWithSiteContext(language: LanguageService) { - // always update language of i18next on site context (language) change - language.getActive().subscribe((lang) => i18next.changeLanguage(lang)); +@Injectable({ providedIn: 'root' }) +export class SiteContextI18nextSynchronizer implements OnDestroy { + sub: Subscription; + + init(i18next: i18n, language: LanguageService) { + // always update language of i18next on site context (language) change + this.sub = + this.sub ?? + language.getActive().subscribe((lang) => i18next.changeLanguage(lang)); + } + + ngOnDestroy() { + this.sub?.unsubscribe(); + } } /** diff --git a/projects/core/src/i18n/i18next/i18next-instance.ts b/projects/core/src/i18n/i18next/i18next-instance.ts new file mode 100644 index 00000000000..c6d4f61fa3e --- /dev/null +++ b/projects/core/src/i18n/i18next/i18next-instance.ts @@ -0,0 +1,16 @@ +import { InjectionToken } from '@angular/core'; +import i18next, { i18n } from 'i18next'; + +/** + * The instance of i18next. + * + * Each SSR request gets its own instance of i18next. + * + * The reference to the static global instance of `i18next` (`import i18next from 'i18next`) + * should not be used anywhere else, because otherwise it would be shared in between all SSR requests + * and can cause concurrency issues. + */ +export const I18NEXT_INSTANCE = new InjectionToken('I18NEXT_INSTANCE', { + providedIn: 'root', + factory: () => i18next.createInstance(), +}); diff --git a/projects/core/src/i18n/i18next/i18next-providers.ts b/projects/core/src/i18n/i18next/i18next-providers.ts index 50f7b6c0cd9..56ffdcc716b 100644 --- a/projects/core/src/i18n/i18next/i18next-providers.ts +++ b/projects/core/src/i18n/i18next/i18next-providers.ts @@ -3,17 +3,20 @@ import { APP_INITIALIZER, Optional, Provider } from '@angular/core'; import { ConfigInitializerService } from '../../config/config-initializer/config-initializer.service'; import { LanguageService } from '../../site-context/facade/language.service'; import { SERVER_REQUEST_ORIGIN } from '../../util/ssr.tokens'; -import { i18nextInit } from './i18next-init'; +import { i18nextInit, SiteContextI18nextSynchronizer } from './i18next-init'; +import { I18NEXT_INSTANCE } from './i18next-instance'; export const i18nextProviders: Provider[] = [ { provide: APP_INITIALIZER, useFactory: i18nextInit, deps: [ + I18NEXT_INSTANCE, ConfigInitializerService, LanguageService, HttpClient, [new Optional(), SERVER_REQUEST_ORIGIN], + SiteContextI18nextSynchronizer, ], multi: true, }, diff --git a/projects/core/src/i18n/i18next/i18next-translation.service.spec.ts b/projects/core/src/i18n/i18next/i18next-translation.service.spec.ts index 759438eb636..b68bb5eab30 100644 --- a/projects/core/src/i18n/i18next/i18next-translation.service.spec.ts +++ b/projects/core/src/i18n/i18next/i18next-translation.service.spec.ts @@ -1,9 +1,10 @@ import * as AngularCore from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import i18next from 'i18next'; +import { i18n } from 'i18next'; import { first, take } from 'rxjs/operators'; import { I18nConfig } from '../config/i18n-config'; import { TranslationChunkService } from '../translation-chunk.service'; +import { I18NEXT_INSTANCE } from './i18next-instance'; import { I18nextTranslationService } from './i18next-translation.service'; const testKey = 'testKey'; @@ -12,6 +13,7 @@ const nonBreakingSpace = String.fromCharCode(160); describe('I18nextTranslationService', () => { let service: I18nextTranslationService; + let i18next: i18n; beforeEach(() => { const mockTranslationChunk = { @@ -32,6 +34,7 @@ describe('I18nextTranslationService', () => { }); service = TestBed.inject(I18nextTranslationService); + i18next = TestBed.inject(I18NEXT_INSTANCE); }); describe('loadChunks', () => { @@ -46,10 +49,13 @@ describe('I18nextTranslationService', () => { }); describe('translate', () => { + beforeEach(() => { + i18next.isInitialized = true; + }); + describe(', when key exists,', () => { beforeEach(() => { spyOn(i18next, 'exists').and.returnValue(true); - i18next.isInitialized = true; }); it('should emit result of i18next.t', () => { diff --git a/projects/core/src/i18n/i18next/i18next-translation.service.ts b/projects/core/src/i18n/i18next/i18next-translation.service.ts index 0ba91b45441..1a160c9c9a1 100644 --- a/projects/core/src/i18n/i18next/i18next-translation.service.ts +++ b/projects/core/src/i18n/i18next/i18next-translation.service.ts @@ -1,9 +1,10 @@ -import { Injectable, isDevMode } from '@angular/core'; -import i18next from 'i18next'; +import { Inject, Injectable, isDevMode } from '@angular/core'; +import { i18n } from 'i18next'; import { Observable } from 'rxjs'; import { I18nConfig } from '../config/i18n-config'; import { TranslationChunkService } from '../translation-chunk.service'; import { TranslationService } from '../translation.service'; +import { I18NEXT_INSTANCE } from './i18next-instance'; @Injectable({ providedIn: 'root' }) export class I18nextTranslationService implements TranslationService { @@ -12,7 +13,9 @@ export class I18nextTranslationService implements TranslationService { constructor( protected config: I18nConfig, - protected translationChunk: TranslationChunkService + protected translationChunk: TranslationChunkService, + // Required param added in 3.0.x as a critical bug fix, not subject to the breaking changes policy + @Inject(I18NEXT_INSTANCE) protected i18next: i18n ) {} translate( @@ -32,34 +35,34 @@ export class I18nextTranslationService implements TranslationService { return new Observable((subscriber) => { const translate = () => { - if (!i18next.isInitialized) { + if (!this.i18next.isInitialized) { return; } - if (i18next.exists(namespacedKey, options)) { - subscriber.next(i18next.t(namespacedKey, options)); + if (this.i18next.exists(namespacedKey, options)) { + subscriber.next(this.i18next.t(namespacedKey, options)); } else { if (whitespaceUntilLoaded) { subscriber.next(this.NON_BREAKING_SPACE); } - i18next.loadNamespaces(chunkName, () => { - if (!i18next.exists(namespacedKey, options)) { + this.i18next.loadNamespaces(chunkName, () => { + if (!this.i18next.exists(namespacedKey, options)) { this.reportMissingKey(key, chunkName); subscriber.next(this.getFallbackValue(namespacedKey)); } else { - subscriber.next(i18next.t(namespacedKey, options)); + subscriber.next(this.i18next.t(namespacedKey, options)); } }); } }; translate(); - i18next.on('languageChanged', translate); - return () => i18next.off('languageChanged', translate); + this.i18next.on('languageChanged', translate); + return () => this.i18next.off('languageChanged', translate); }); } loadChunks(chunkNames: string | string[]): Promise { - return i18next.loadNamespaces(chunkNames); + return this.i18next.loadNamespaces(chunkNames); } /** From 40a0dd2b8fca4b91b0c96e1c28e47c9c7258c61e Mon Sep 17 00:00:00 2001 From: Parthlakhani Date: Wed, 20 Jan 2021 15:45:03 -0500 Subject: [PATCH 12/30] auto-select option if only one option is available (#10619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * GH-8610 feat: select option if only one is available * refactor: replace subscribe with pipe * revert order of methods * up * up * add enters * up * up * up * refactor: add check for form * remove auto selection for approval process * GH-8610 add unit tests * feat: add negative scenario unit tests * refactor: add unit tests for budget-form * refactor: change budget unit tests * my proposal * Apply suggestions from code review Co-authored-by: Michał Gruca * feat: add unit tests for autoselect * up * up * up Co-authored-by: Michał Gruca --- .../budget/form/budget-form.component.spec.ts | 65 +++++++++++++--- .../budget/form/budget-form.component.ts | 18 ++++- .../form/cost-center-form.component.spec.ts | 71 +++++++++++++---- .../form/cost-center-form.component.ts | 18 ++++- .../form/permission-form.component.spec.ts | 51 +++++++++++-- .../form/permission-form.component.ts | 20 ++++- .../unit/form/unit-form.component.spec.ts | 76 ++++++++++++------- .../unit/form/unit-form.component.ts | 47 +++++++----- .../form/user-group-form.component.spec.ts | 41 +++++++--- .../form/user-group-form.component.ts | 9 ++- .../user/form/user-form.component.spec.ts | 28 ++++++- .../user/form/user-form.component.ts | 10 ++- 12 files changed, 356 insertions(+), 98 deletions(-) diff --git a/feature-libs/organization/administration/components/budget/form/budget-form.component.spec.ts b/feature-libs/organization/administration/components/budget/form/budget-form.component.spec.ts index 3a1ebde2065..c164fc19c86 100644 --- a/feature-libs/organization/administration/components/budget/form/budget-form.component.spec.ts +++ b/feature-libs/organization/administration/components/budget/form/budget-form.component.spec.ts @@ -3,11 +3,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NgSelectModule } from '@ng-select/ng-select'; -import { CurrencyService, I18nTestingModule } from '@spartacus/core'; -import { OrgUnitService } from '@spartacus/organization/administration/core'; +import { Currency, CurrencyService, I18nTestingModule } from '@spartacus/core'; +import { + B2BUnitNode, + OrgUnitService, +} from '@spartacus/organization/administration/core'; import { FormErrorsComponent } from '@spartacus/storefront'; import { UrlTestingModule } from 'projects/core/src/routing/configurable-routes/url-translation/testing/url-testing.module'; -import { of } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { FormTestingModule } from '../../shared/form/form.testing.module'; import { BudgetItemService } from '../services/budget-item.service'; import { BudgetFormComponent } from './budget-form.component'; @@ -26,15 +29,16 @@ const mockForm = new FormGroup({ budget: new FormControl(), }); +const activeUnitList$: BehaviorSubject = new BehaviorSubject([]); +const currencies$: BehaviorSubject = new BehaviorSubject([]); + class MockOrgUnitService { - getActiveUnitList() { - return of([]); - } + getActiveUnitList = () => activeUnitList$.asObservable(); loadList() {} } class MockCurrencyService { - getAll() {} + getAll = () => currencies$.asObservable(); } class MockItemService { @@ -128,11 +132,49 @@ describe('BudgetFormComponent', () => { expect(b2bUnitService.loadList).toHaveBeenCalled(); }); + describe('autoSelect uid', () => { + beforeEach(() => { + component.form = mockForm; + component.form.get('orgUnit.uid').setValue(null); + }); + + it('should auto-select unit if only one is available', () => { + activeUnitList$.next([{ id: 'test' }]); + fixture.detectChanges(); + expect(component.form.get('orgUnit.uid').value).toEqual('test'); + }); + + it('should not auto-select unit if more than one is available', () => { + activeUnitList$.next([{ id: 'test1' }, { id: 'test2' }]); + fixture.detectChanges(); + expect(component.form.get('orgUnit.uid').value).toBeNull(); + }); + }); + + describe('autoSelect currency', () => { + beforeEach(() => { + component.form = mockForm; + component.form.get('currency.isocode').setValue(null); + }); + + it('should auto-select currency if only one is available', () => { + currencies$.next([{ isocode: 'test' }]); + fixture.detectChanges(); + expect(component.form.get('currency.isocode').value).toEqual('test'); + }); + + it('should not auto-select currency if more than one is available', () => { + currencies$.next([{ isocode: 'test' }, { isocode: 'test' }]); + fixture.detectChanges(); + expect(component.form.get('currency.isocode').value).toBeNull(); + }); + }); + describe('createCodeWithName', () => { it('should set code field value if empty based on provided name value', () => { - mockForm.get('name').patchValue('Unit Test Value'); - mockForm.get('code').patchValue(undefined); component.form = mockForm; + component.form.get('name').patchValue('Unit Test Value'); + component.form.get('code').patchValue(undefined); component.createCodeWithName( component.form.get('name'), component.form.get('code') @@ -140,10 +182,11 @@ describe('BudgetFormComponent', () => { expect(component.form.get('code').value).toEqual('unit-test-value'); }); + it('should prevent setting code if value is provided for this field', () => { - mockForm.get('name').patchValue('Unit Test Value'); - mockForm.get('code').patchValue('test code'); component.form = mockForm; + component.form.get('name').patchValue('Unit Test Value'); + component.form.get('code').patchValue('test code'); component.createCodeWithName( component.form.get('name'), component.form.get('code') diff --git a/feature-libs/organization/administration/components/budget/form/budget-form.component.ts b/feature-libs/organization/administration/components/budget/form/budget-form.component.ts index 055786f659d..4a6fa73c691 100644 --- a/feature-libs/organization/administration/components/budget/form/budget-form.component.ts +++ b/feature-libs/organization/administration/components/budget/form/budget-form.component.ts @@ -7,6 +7,7 @@ import { OrgUnitService, } from '@spartacus/organization/administration/core'; import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; import { CurrentItemService } from '../../shared/current-item.service'; import { ItemService } from '../../shared/item.service'; import { BudgetItemService } from '../services/budget-item.service'; @@ -32,8 +33,21 @@ import { createCodeForEntityName } from '../../shared/utility/entity-code'; export class BudgetFormComponent implements OnInit { form: FormGroup = this.itemService.getForm(); - units$: Observable = this.unitService.getActiveUnitList(); - currencies$: Observable = this.currencyService.getAll(); + units$: Observable = this.unitService.getActiveUnitList().pipe( + tap((units) => { + if (units.length === 1) { + this.form?.get('orgUnit.uid')?.setValue(units[0]?.id); + } + }) + ); + + currencies$: Observable = this.currencyService.getAll().pipe( + tap((currency) => { + if (currency.length === 1) { + this.form?.get('currency.isocode')?.setValue(currency[0]?.isocode); + } + }) + ); constructor( protected itemService: ItemService, diff --git a/feature-libs/organization/administration/components/cost-center/form/cost-center-form.component.spec.ts b/feature-libs/organization/administration/components/cost-center/form/cost-center-form.component.spec.ts index b1184fb7b97..1e6c8a220f4 100644 --- a/feature-libs/organization/administration/components/cost-center/form/cost-center-form.component.spec.ts +++ b/feature-libs/organization/administration/components/cost-center/form/cost-center-form.component.spec.ts @@ -2,11 +2,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NgSelectModule } from '@ng-select/ng-select'; -import { CurrencyService, I18nTestingModule } from '@spartacus/core'; -import { OrgUnitService } from '@spartacus/organization/administration/core'; +import { Currency, CurrencyService, I18nTestingModule } from '@spartacus/core'; +import { + B2BUnitNode, + OrgUnitService, +} from '@spartacus/organization/administration/core'; import { FormErrorsComponent } from '@spartacus/storefront'; import { UrlTestingModule } from 'projects/core/src/routing/configurable-routes/url-translation/testing/url-testing.module'; -import { of } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { FormTestingModule } from '../../shared/form/form.testing.module'; import { CostCenterItemService } from '../services/cost-center-item.service'; import { CostCenterFormComponent } from './cost-center-form.component'; @@ -22,23 +25,20 @@ const mockForm = new FormGroup({ }), }); +const activeUnitList$: BehaviorSubject = new BehaviorSubject([]); +const currencies$: BehaviorSubject = new BehaviorSubject([]); + class MockOrgUnitService { - getActiveUnitList() { - return of([]); - } + getActiveUnitList = () => activeUnitList$.asObservable(); loadList() {} } class MockCurrencyService { - getAll() { - return of(); - } + getAll = () => currencies$.asObservable(); } class MockItemService { - getForm() { - return mockForm; - } + getForm() {} } describe('CostCenterFormComponent', () => { @@ -85,6 +85,7 @@ describe('CostCenterFormComponent', () => { }); it('should render form controls', () => { + component.form = mockForm; fixture.detectChanges(); const formControls = fixture.debugElement.queryAll(By.css('input')); expect(formControls.length).toBeGreaterThan(0); @@ -105,11 +106,49 @@ describe('CostCenterFormComponent', () => { expect(b2bUnitService.getActiveUnitList).toHaveBeenCalled(); }); + describe('autoSelect uid', () => { + beforeEach(() => { + component.form = mockForm; + component.form.get('unit.uid').setValue(null); + }); + + it('should auto-select unit if only one is available', () => { + activeUnitList$.next([{ id: 'test' }]); + fixture.detectChanges(); + expect(component.form.get('unit.uid').value).toEqual('test'); + }); + + it('should not auto-select unit if more than one is available', () => { + activeUnitList$.next([{ id: 'test1' }, { id: 'test2' }]); + fixture.detectChanges(); + expect(component.form.get('unit.uid').value).toBeNull(); + }); + }); + + describe('autoSelect currency', () => { + beforeEach(() => { + component.form = mockForm; + component.form.get('currency.isocode').setValue(null); + }); + + it('should auto-select currency if only one is available', () => { + currencies$.next([{ isocode: 'test' }]); + fixture.detectChanges(); + expect(component.form.get('currency.isocode').value).toEqual('test'); + }); + + it('should not auto-select currency if more than one is available', () => { + currencies$.next([{ isocode: 'test1' }, { isocode: 'test2' }]); + fixture.detectChanges(); + expect(component.form.get('currency.isocode').value).toBeNull(); + }); + }); + describe('createCodeWithName', () => { it('should set code field value if empty based on provided name value', () => { - mockForm.get('name').patchValue('Unit Test Value'); - mockForm.get('code').patchValue(undefined); component.form = mockForm; + component.form.get('name').patchValue('Unit Test Value'); + component.form.get('code').patchValue(undefined); component.createCodeWithName( component.form.get('name'), component.form.get('code') @@ -118,9 +157,9 @@ describe('CostCenterFormComponent', () => { expect(component.form.get('code').value).toEqual('unit-test-value'); }); it('should prevent setting code if value is provided for this field', () => { - mockForm.get('name').patchValue('Unit Test Value'); - mockForm.get('code').patchValue('test code'); component.form = mockForm; + component.form.get('name').patchValue('Unit Test Value'); + component.form.get('code').patchValue('test code'); component.createCodeWithName( component.form.get('name'), component.form.get('code') diff --git a/feature-libs/organization/administration/components/cost-center/form/cost-center-form.component.ts b/feature-libs/organization/administration/components/cost-center/form/cost-center-form.component.ts index 44f9e1baa4f..4b8263294e5 100644 --- a/feature-libs/organization/administration/components/cost-center/form/cost-center-form.component.ts +++ b/feature-libs/organization/administration/components/cost-center/form/cost-center-form.component.ts @@ -6,6 +6,7 @@ import { OrgUnitService, } from '@spartacus/organization/administration/core'; import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; import { CurrentItemService } from '../../shared/current-item.service'; import { ItemService } from '../../shared/item.service'; import { CostCenterItemService } from '../services/cost-center-item.service'; @@ -42,8 +43,21 @@ export class CostCenterFormComponent { } } - units$: Observable = this.unitService.getActiveUnitList(); - currencies$: Observable = this.currencyService.getAll(); + units$: Observable = this.unitService.getActiveUnitList().pipe( + tap((units) => { + if (units.length === 1) { + this.form?.get('unit.uid')?.setValue(units[0]?.id); + } + }) + ); + + currencies$: Observable = this.currencyService.getAll().pipe( + tap((currency) => { + if (currency.length === 1) { + this.form?.get('currency.isocode')?.setValue(currency[0]?.isocode); + } + }) + ); constructor( protected itemService: ItemService, diff --git a/feature-libs/organization/administration/components/permission/form/permission-form.component.spec.ts b/feature-libs/organization/administration/components/permission/form/permission-form.component.spec.ts index 95afee9b5dd..0fca704a6ab 100644 --- a/feature-libs/organization/administration/components/permission/form/permission-form.component.spec.ts +++ b/feature-libs/organization/administration/components/permission/form/permission-form.component.spec.ts @@ -3,17 +3,19 @@ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NgSelectModule } from '@ng-select/ng-select'; import { + Currency, CurrencyService, I18nTestingModule, OrderApprovalPermissionType, } from '@spartacus/core'; import { + B2BUnitNode, OrgUnitService, PermissionService, } from '@spartacus/organization/administration/core'; import { FormErrorsComponent } from '@spartacus/storefront'; import { UrlTestingModule } from 'projects/core/src/routing/configurable-routes/url-translation/testing/url-testing.module'; -import { of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { FormTestingModule } from '../../shared/form/form.testing.module'; import { PermissionItemService } from '../services/permission-item.service'; import { PermissionFormComponent } from './permission-form.component'; @@ -35,15 +37,16 @@ const mockForm = new FormGroup({ }), }); +const activeUnitList$: BehaviorSubject = new BehaviorSubject([]); +const currencies$: BehaviorSubject = new BehaviorSubject([]); + class MockOrgUnitService { - getActiveUnitList() { - return of([]); - } + getActiveUnitList = () => activeUnitList$.asObservable(); loadList() {} } class MockCurrencyService { - getAll() {} + getAll = () => currencies$.asObservable(); } class MockItemService { @@ -137,4 +140,42 @@ describe('PermissionFormComponent', () => { fixture.detectChanges(); expect(b2bUnitService.loadList).toHaveBeenCalled(); }); + + describe('autoSelect uid', () => { + beforeEach(() => { + component.form = mockForm; + component.form.get('orgUnit.uid').setValue(null); + }); + + it('should auto-select unit if only one is available', () => { + activeUnitList$.next([{ id: 'test' }]); + fixture.detectChanges(); + expect(component.form.get('orgUnit.uid').value).toEqual('test'); + }); + + it('should not auto-select unit if more than one is available', () => { + activeUnitList$.next([{ id: 'test1' }, { id: 'test2' }]); + fixture.detectChanges(); + expect(component.form.get('orgUnit.uid').value).toBeNull(); + }); + }); + + describe('autoSelect currency', () => { + beforeEach(() => { + component.form = mockForm; + component.form.get('currency.isocode').setValue(null); + }); + + it('should auto-select currency if only one is available', () => { + currencies$.next([{ isocode: 'test' }]); + fixture.detectChanges(); + expect(component.form.get('currency.isocode').value).toEqual('test'); + }); + + it('should not auto-select currency if more than one is available', () => { + currencies$.next([{ isocode: 'test1' }, { isocode: 'test2' }]); + fixture.detectChanges(); + expect(component.form.get('currency.isocode').value).toBeNull(); + }); + }); }); diff --git a/feature-libs/organization/administration/components/permission/form/permission-form.component.ts b/feature-libs/organization/administration/components/permission/form/permission-form.component.ts index b7a3b4b34ac..6384c06a7e4 100644 --- a/feature-libs/organization/administration/components/permission/form/permission-form.component.ts +++ b/feature-libs/organization/administration/components/permission/form/permission-form.component.ts @@ -13,6 +13,7 @@ import { PermissionService, } from '@spartacus/organization/administration/core'; import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; import { CurrentItemService } from '../../shared/current-item.service'; import { ItemService } from '../../shared/item.service'; import { CurrentPermissionService } from '../services/current-permission.service'; @@ -37,11 +38,26 @@ import { PermissionItemService } from '../services/permission-item.service'; export class PermissionFormComponent implements OnInit { form: FormGroup = this.itemService.getForm(); - units$: Observable = this.unitService.getActiveUnitList(); - currencies$: Observable = this.currencyService.getAll(); + units$: Observable = this.unitService.getActiveUnitList().pipe( + tap((units) => { + if (units.length === 1) { + this.form?.get('orgUnit.uid')?.setValue(units[0]?.id); + } + }) + ); + + currencies$: Observable = this.currencyService.getAll().pipe( + tap((currency) => { + if (currency.length === 1) { + this.form?.get('currency.isocode')?.setValue(currency[0]?.isocode); + } + }) + ); + types$: Observable< OrderApprovalPermissionType[] > = this.permissionService.getTypes(); + periods = Object.keys(Period); constructor( diff --git a/feature-libs/organization/administration/components/unit/form/unit-form.component.spec.ts b/feature-libs/organization/administration/components/unit/form/unit-form.component.spec.ts index 739174be950..2ca98a53fea 100644 --- a/feature-libs/organization/administration/components/unit/form/unit-form.component.spec.ts +++ b/feature-libs/organization/administration/components/unit/form/unit-form.component.spec.ts @@ -3,10 +3,13 @@ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NgSelectModule } from '@ng-select/ng-select'; import { I18nTestingModule } from '@spartacus/core'; -import { OrgUnitService } from '@spartacus/organization/administration/core'; +import { + B2BUnitNode, + OrgUnitService, +} from '@spartacus/organization/administration/core'; import { FormErrorsComponent } from '@spartacus/storefront'; import { UrlTestingModule } from 'projects/core/src/routing/configurable-routes/url-translation/testing/url-testing.module'; -import { of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { FormTestingModule } from '../../shared/form/form.testing.module'; import { UnitItemService } from '../services/unit-item.service'; import { UnitFormComponent } from './unit-form.component'; @@ -22,10 +25,11 @@ const mockForm = new FormGroup({ }), }); +const activeUnitList$: BehaviorSubject = new BehaviorSubject([]); +const unit$: BehaviorSubject = new BehaviorSubject(null); + class MockOrgUnitService { - getActiveUnitList() { - return of([]); - } + getActiveUnitList = () => activeUnitList$.asObservable(); loadList() {} getApprovalProcesses() { return of(); @@ -34,7 +38,7 @@ class MockOrgUnitService { class MockItemService { get unit$() { - return of('uid'); + return unit$.asObservable(); } getForm() { return mockForm; @@ -100,33 +104,51 @@ describe('UnitFormComponent', () => { it('should disable parentOrgUnit form control', () => { component.createChildUnit = true; - let result: FormGroup; - component.form$.subscribe((form) => (result = form)).unsubscribe(); - expect(result.get('parentOrgUnit.uid').disabled).toBeTruthy(); + component.units$.subscribe().unsubscribe(); + expect(component.formGroup.get('parentOrgUnit.uid').disabled).toBeTruthy(); + }); + + describe('autoSelect uid', () => { + beforeEach(() => { + component.formGroup.get('parentOrgUnit.uid').setValue(null); + }); + + it('should auto-select unit if only one is available', () => { + activeUnitList$.next([{ id: 'test' }]); + fixture.detectChanges(); + expect(component.formGroup.get('parentOrgUnit.uid').value).toEqual( + 'test' + ); + }); + + it('should not auto-select unit if more than one is available', () => { + activeUnitList$.next([{ id: 'test1' }, { id: 'test2' }]); + fixture.detectChanges(); + expect(component.formGroup.get('parentOrgUnit.uid').value).toBeNull(); + }); }); describe('createUidWithName', () => { it('should set uid field value if empty based on provided name value', () => { - component.form$ - .subscribe((form) => { - form.get('name').patchValue('Unit Test Value'); - form.get('uid').patchValue(undefined); - component.createUidWithName(form.get('name'), form.get('uid')); - - expect(form.get('uid').value).toEqual('unit-test-value'); - }) - .unsubscribe(); + component.formGroup.get('name').patchValue('Unit Test Value'); + component.formGroup.get('uid').patchValue(undefined); + component.createUidWithName( + component.formGroup.get('name'), + component.formGroup.get('uid') + ); + + expect(component.formGroup.get('uid').value).toEqual('unit-test-value'); }); + it('should prevent setting uid if value is provided for this field', () => { - component.form$ - .subscribe((form) => { - form.get('name').patchValue('Unit Test Value'); - form.get('uid').patchValue('test uid'); - component.createUidWithName(form.get('name'), form.get('uid')); - - expect(form.get('uid').value).toEqual('test uid'); - }) - .unsubscribe(); + component.formGroup.get('name').patchValue('Unit Test Value'); + component.formGroup.get('uid').patchValue('test uid'); + component.createUidWithName( + component.formGroup.get('name'), + component.formGroup.get('uid') + ); + + expect(component.formGroup.get('uid').value).toEqual('test uid'); }); }); }); diff --git a/feature-libs/organization/administration/components/unit/form/unit-form.component.ts b/feature-libs/organization/administration/components/unit/form/unit-form.component.ts index 820ebff515f..1a3fdbbce6c 100644 --- a/feature-libs/organization/administration/components/unit/form/unit-form.component.ts +++ b/feature-libs/organization/administration/components/unit/form/unit-form.component.ts @@ -4,13 +4,14 @@ import { Input, OnInit, } from '@angular/core'; +import { FormGroup } from '@angular/forms'; import { B2BApprovalProcess, B2BUnit } from '@spartacus/core'; import { B2BUnitNode, OrgUnitService, } from '@spartacus/organization/administration/core'; -import { Observable } from 'rxjs'; -import { filter, map, switchMap } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { filter, map, switchMap, tap } from 'rxjs/operators'; import { CurrentItemService } from '../../shared/current-item.service'; import { ItemService } from '../../shared/item.service'; import { CurrentUnitService } from '../services/current-unit.service'; @@ -39,24 +40,34 @@ export class UnitFormComponent implements OnInit { @Input() createChildUnit = false; - form$: Observable = this.itemService.unit$.pipe( - map((unit) => { - const form = this.itemService.getForm(); - form.get('parentOrgUnit.uid')?.setValue(unit); + /* + * TODO: 4.0: rename to `form` #10710 + */ + formGroup: FormGroup = this.itemService.getForm(); + + /* + * deprecated since 3.0, use `formGroup` instead + */ + form$: Observable = of(this.formGroup); + + units$: Observable = this.itemService.unit$.pipe( + tap((unit) => { + this.formGroup.get('parentOrgUnit.uid')?.setValue(unit); if (this.createChildUnit) { - form.get('parentOrgUnit')?.disable(); + this.formGroup.get('parentOrgUnit')?.disable(); } - return form; - }) - ); - - units$: Observable = this.form$.pipe( - switchMap((form) => - this.unitService - .getActiveUnitList() - .pipe( - map((units) => units.filter((unit) => unit.id !== form?.value.uid)) - ) + }), + switchMap(() => + this.unitService.getActiveUnitList().pipe( + map((units) => + units.filter((unit) => unit.id !== this.formGroup?.value.uid) + ), + tap((units) => { + if (units.length === 1) { + this.formGroup?.get('parentOrgUnit.uid')?.setValue(units[0]?.id); + } + }) + ) ) ); diff --git a/feature-libs/organization/administration/components/user-group/form/user-group-form.component.spec.ts b/feature-libs/organization/administration/components/user-group/form/user-group-form.component.spec.ts index 6053bddd3dd..1cd1446fbd2 100644 --- a/feature-libs/organization/administration/components/user-group/form/user-group-form.component.spec.ts +++ b/feature-libs/organization/administration/components/user-group/form/user-group-form.component.spec.ts @@ -3,10 +3,13 @@ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NgSelectModule } from '@ng-select/ng-select'; import { I18nTestingModule } from '@spartacus/core'; -import { OrgUnitService } from '@spartacus/organization/administration/core'; +import { + B2BUnitNode, + OrgUnitService, +} from '@spartacus/organization/administration/core'; import { FormErrorsComponent } from '@spartacus/storefront'; import { UrlTestingModule } from 'projects/core/src/routing/configurable-routes/url-translation/testing/url-testing.module'; -import { of } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { FormTestingModule } from '../../shared/form/form.testing.module'; import { UserGroupItemService } from '../services/user-group-item.service'; import { UserGroupFormComponent } from './user-group-form.component'; @@ -19,10 +22,10 @@ const mockForm = new FormGroup({ }), }); +const activeUnitList$: BehaviorSubject = new BehaviorSubject([]); + class MockOrgUnitService { - getActiveUnitList() { - return of([]); - } + getActiveUnitList = () => activeUnitList$.asObservable(); loadList() {} } @@ -95,11 +98,30 @@ describe('UserGroupFormComponent', () => { expect(b2bUnitService.loadList).toHaveBeenCalled(); }); + describe('autoSelect uid', () => { + beforeEach(() => { + component.form = mockForm; + component.form.get('orgUnit.uid').setValue(null); + }); + + it('should auto-select unit if only one is available', () => { + activeUnitList$.next([{ id: 'test' }]); + fixture.detectChanges(); + expect(component.form.get('orgUnit.uid').value).toEqual('test'); + }); + + it('should not auto-select unit if more than one is available', () => { + activeUnitList$.next([{ id: 'test1' }, { id: 'test2' }]); + fixture.detectChanges(); + expect(component.form.get('orgUnit.uid').value).toBeNull(); + }); + }); + describe('createUidWithName', () => { it('should set uid field value if empty based on provided name value', () => { - mockForm.get('name').patchValue('Unit Test Value'); - mockForm.get('uid').patchValue(undefined); component.form = mockForm; + component.form.get('name').patchValue('Unit Test Value'); + component.form.get('uid').patchValue(undefined); component.createUidWithName( component.form.get('name'), component.form.get('uid') @@ -107,10 +129,11 @@ describe('UserGroupFormComponent', () => { expect(component.form.get('uid').value).toEqual('unit-test-value'); }); + it('should prevent setting uid if value is provided for this field', () => { - mockForm.get('name').patchValue('Unit Test Value'); - mockForm.get('uid').patchValue('test uid'); component.form = mockForm; + component.form.get('name').patchValue('Unit Test Value'); + component.form.get('uid').patchValue('test uid'); component.createUidWithName( component.form.get('name'), component.form.get('uid') diff --git a/feature-libs/organization/administration/components/user-group/form/user-group-form.component.ts b/feature-libs/organization/administration/components/user-group/form/user-group-form.component.ts index ae30df75498..7e855fbc21f 100644 --- a/feature-libs/organization/administration/components/user-group/form/user-group-form.component.ts +++ b/feature-libs/organization/administration/components/user-group/form/user-group-form.component.ts @@ -6,6 +6,7 @@ import { UserGroup, } from '@spartacus/organization/administration/core'; import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; import { ItemService } from '../../shared/item.service'; import { UserGroupItemService } from '../services/user-group-item.service'; import { createCodeForEntityName } from '../../shared/utility/entity-code'; @@ -26,7 +27,13 @@ export class UserGroupFormComponent implements OnInit { form: FormGroup = this.itemService.getForm(); // getList ??? - units$: Observable = this.unitService.getActiveUnitList(); + units$: Observable = this.unitService.getActiveUnitList().pipe( + tap((units) => { + if (units.length === 1) { + this.form?.get('orgUnit.uid')?.setValue(units[0]?.id); + } + }) + ); constructor( protected itemService: ItemService, diff --git a/feature-libs/organization/administration/components/user/form/user-form.component.spec.ts b/feature-libs/organization/administration/components/user/form/user-form.component.spec.ts index a917cf9d869..fb87f1eaadb 100644 --- a/feature-libs/organization/administration/components/user/form/user-form.component.spec.ts +++ b/feature-libs/organization/administration/components/user/form/user-form.component.spec.ts @@ -14,12 +14,13 @@ import { UserService, } from '@spartacus/core'; import { + B2BUnitNode, B2BUserService, OrgUnitService, } from '@spartacus/organization/administration/core'; import { FormErrorsComponent } from '@spartacus/storefront'; import { UrlTestingModule } from 'projects/core/src/routing/configurable-routes/url-translation/testing/url-testing.module'; -import { Observable, of } from 'rxjs'; +import { BehaviorSubject, Observable, of } from 'rxjs'; import { FormTestingModule } from '../../shared/form/form.testing.module'; import { UserItemService } from '../services/user-item.service'; import { UserFormComponent } from './user-form.component'; @@ -38,6 +39,8 @@ const mockForm = new FormGroup({ roles: new FormArray([]), }); +const activeUnitList$: BehaviorSubject = new BehaviorSubject([]); + class MockUserService { getTitles(): Observable { return of(); @@ -58,9 +61,7 @@ class MockB2BUserService implements Partial { } class MockOrgUnitService { - getActiveUnitList() { - return of([]); - } + getActiveUnitList = () => activeUnitList$.asObservable(); loadList() {} } @@ -131,4 +132,23 @@ describe('UserFormComponent', () => { fixture.detectChanges(); expect(b2bUnitService.loadList).toHaveBeenCalled(); }); + + describe('autoSelect uid', () => { + beforeEach(() => { + component.form = mockForm; + component.form.get('orgUnit.uid').setValue(null); + }); + + it('should auto-select unit if only one is available', () => { + activeUnitList$.next([{ id: 'test' }]); + fixture.detectChanges(); + expect(component.form.get('orgUnit.uid').value).toEqual('test'); + }); + + it('should not auto-select unit if more than one is available', () => { + activeUnitList$.next([{ id: 'test1' }, { id: 'test2' }]); + fixture.detectChanges(); + expect(component.form.get('orgUnit.uid').value).toBeNull(); + }); + }); }); diff --git a/feature-libs/organization/administration/components/user/form/user-form.component.ts b/feature-libs/organization/administration/components/user/form/user-form.component.ts index 42b3a8b5e52..ceab2ea447d 100644 --- a/feature-libs/organization/administration/components/user/form/user-form.component.ts +++ b/feature-libs/organization/administration/components/user/form/user-form.component.ts @@ -12,6 +12,7 @@ import { OrgUnitService, } from '@spartacus/organization/administration/core'; import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; import { CurrentItemService } from '../../shared/current-item.service'; import { ItemService } from '../../shared/item.service'; import { CurrentUserService } from '../services/current-user.service'; @@ -48,7 +49,14 @@ export class UserFormComponent implements OnInit { } } - units$: Observable = this.unitService.getActiveUnitList(); + units$: Observable = this.unitService.getActiveUnitList().pipe( + tap((units) => { + if (units.length === 1) { + this.form?.get('orgUnit.uid').setValue(units[0]?.id); + } + }) + ); + titles$: Observable = this.userService.getTitles(); availableRoles: B2BUserRole[] = this.b2bUserService.getAllRoles(); From 615869c13de43d20df5c92db440318f7fa400c65 Mon Sep 17 00:00:00 2001 From: Caine Rotherham Date: Thu, 21 Jan 2021 08:18:30 +0100 Subject: [PATCH 13/30] test: Refactor my company e2e tests (#10566) Closes: #9811 --- .../b2b/my-company/config/budget.config.ts | 10 +- .../my-company/config/cost-center.config.ts | 10 +- .../b2b/my-company/config/purchase-limit.ts | 22 +- .../helpers/b2b/my-company/config/unit.ts | 14 +- .../b2b/my-company/config/user-group.ts | 8 +- .../helpers/b2b/my-company/config/user.ts | 13 +- .../assignments.ts} | 11 +- .../helpers/b2b/my-company/features/create.ts | 62 ++++ .../b2b/my-company/features/disable.ts | 76 +++++ .../helpers/b2b/my-company/features/list.ts | 22 ++ .../b2b/my-company/features/nested-list.ts | 30 ++ .../helpers/b2b/my-company/features/update.ts | 57 ++++ .../b2b/my-company/features/user-password.ts | 2 +- .../b2b/my-company/features/utils/form.ts | 158 ++++++++++ .../utils/list.ts} | 48 +-- .../my-company/models/my-company.config.ts | 15 - .../b2b/my-company/models/my-company.model.ts | 6 + .../b2b/my-company/my-company-features.ts | 12 + .../helpers/b2b/my-company/my-company-form.ts | 297 ------------------ .../b2b/my-company/my-company.utils.ts | 30 -- ...ts.flaky-e2e-spec.ts => units.e2e-spec.ts} | 0 ...rs.flaky-e2e-spec.ts => users.e2e-spec.ts} | 0 22 files changed, 494 insertions(+), 409 deletions(-) rename projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/{my-company-assign.ts => features/assignments.ts} (97%) create mode 100644 projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/create.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/disable.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/list.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/nested-list.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/update.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/utils/form.ts rename projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/{my-company-list.ts => features/utils/list.ts} (84%) delete mode 100644 projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-form.ts rename projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/{units.flaky-e2e-spec.ts => units.e2e-spec.ts} (100%) rename projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/{users.flaky-e2e-spec.ts => users.e2e-spec.ts} (100%) diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/budget.config.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/budget.config.ts index def8050a8b2..3152ee656e1 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/budget.config.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/budget.config.ts @@ -1,13 +1,12 @@ import { FULL_BASE_URL_EN_USD } from '../../../site-context-selector'; import { randomString } from '../../../user'; -import { INPUT_TYPE, MyCompanyConfig } from '../models'; +import { INPUT_TYPE, MyCompanyConfig, MY_COMPANY_FEATURE } from '../models'; export const budgetConfig: MyCompanyConfig = { name: 'Budget', baseUrl: `${FULL_BASE_URL_EN_USD}/organization/budgets`, apiEndpoint: '/users/current/budgets', objectType: 'budgets', - canDisable: true, verifyStatusInDetails: true, rows: [ { @@ -104,4 +103,11 @@ export const budgetConfig: MyCompanyConfig = { apiEndpoint: '**/constcenters**', }, ], + features: [ + MY_COMPANY_FEATURE.CREATE, + MY_COMPANY_FEATURE.DISABLE, + MY_COMPANY_FEATURE.UPDATE, + MY_COMPANY_FEATURE.LIST, + MY_COMPANY_FEATURE.ASSIGNMENTS, + ], }; diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/cost-center.config.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/cost-center.config.ts index fba4b2e2ba8..b8d1481d1cc 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/cost-center.config.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/cost-center.config.ts @@ -1,13 +1,12 @@ import { FULL_BASE_URL_EN_USD } from '../../../site-context-selector'; import { randomString } from '../../../user'; -import { INPUT_TYPE, MyCompanyConfig } from '../models'; +import { INPUT_TYPE, MyCompanyConfig, MY_COMPANY_FEATURE } from '../models'; export const costCenterConfig: MyCompanyConfig = { name: 'Cost Center', baseUrl: `${FULL_BASE_URL_EN_USD}/organization/cost-centers`, apiEndpoint: '/costcenters', objectType: 'costCenters', - canDisable: true, verifyStatusInDetails: true, rows: [ { @@ -72,4 +71,11 @@ export const costCenterConfig: MyCompanyConfig = { manageAssignments: true, }, ], + features: [ + MY_COMPANY_FEATURE.CREATE, + MY_COMPANY_FEATURE.DISABLE, + MY_COMPANY_FEATURE.UPDATE, + MY_COMPANY_FEATURE.LIST, + MY_COMPANY_FEATURE.ASSIGNMENTS, + ], }; diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/purchase-limit.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/purchase-limit.ts index 4542c7341a3..220e878acd0 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/purchase-limit.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/purchase-limit.ts @@ -1,6 +1,6 @@ import { FULL_BASE_URL_EN_USD } from '../../../site-context-selector'; import { randomString } from '../../../user'; -import { INPUT_TYPE, MyCompanyConfig } from '../models'; +import { INPUT_TYPE, MyCompanyConfig, MY_COMPANY_FEATURE } from '../models'; export const purchaseLimitConfigs: MyCompanyConfig[] = [ { @@ -9,7 +9,6 @@ export const purchaseLimitConfigs: MyCompanyConfig[] = [ apiEndpoint: '/users/current/orderApprovalPermissions', objectType: 'orderApprovalPermissions', selectOptionsEndpoint: '*orderApprovalPermissionTypes*', - canDisable: true, verifyStatusInDetails: true, rows: [ { @@ -57,6 +56,13 @@ export const purchaseLimitConfigs: MyCompanyConfig[] = [ showInDetails: true, }, ], + features: [ + MY_COMPANY_FEATURE.CREATE, + MY_COMPANY_FEATURE.DISABLE, + MY_COMPANY_FEATURE.UPDATE, + MY_COMPANY_FEATURE.LIST, + , + ], }, { name: 'Purchase Limit', @@ -65,7 +71,6 @@ export const purchaseLimitConfigs: MyCompanyConfig[] = [ apiEndpoint: '/users/current/orderApprovalPermissions', objectType: 'orderApprovalPermissions', selectOptionsEndpoint: '*orderApprovalPermissionTypes*', - disableListChecking: true, rows: [ { label: 'Code', @@ -132,6 +137,11 @@ export const purchaseLimitConfigs: MyCompanyConfig[] = [ showInDetails: true, }, ], + features: [ + MY_COMPANY_FEATURE.CREATE, + MY_COMPANY_FEATURE.DISABLE, + MY_COMPANY_FEATURE.UPDATE, + ], }, { name: 'Purchase Limit', @@ -140,7 +150,6 @@ export const purchaseLimitConfigs: MyCompanyConfig[] = [ apiEndpoint: '/users/current/orderApprovalPermissions', objectType: 'orderApprovalPermissions', selectOptionsEndpoint: '*orderApprovalPermissionTypes*', - disableListChecking: true, rows: [ { label: 'Code', @@ -217,5 +226,10 @@ export const purchaseLimitConfigs: MyCompanyConfig[] = [ showInDetails: true, }, ], + features: [ + MY_COMPANY_FEATURE.CREATE, + MY_COMPANY_FEATURE.DISABLE, + MY_COMPANY_FEATURE.UPDATE, + ], }, ]; diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/unit.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/unit.ts index 833559d3a90..d2475108d69 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/unit.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/unit.ts @@ -1,6 +1,6 @@ import { FULL_BASE_URL_EN_USD } from '../../../site-context-selector'; import { randomString } from '../../../user'; -import { INPUT_TYPE, MyCompanyConfig } from '../models'; +import { INPUT_TYPE, MyCompanyConfig, MY_COMPANY_FEATURE } from '../models'; import { costCenterConfig } from './cost-center.config'; import { userConfig } from './user'; @@ -67,8 +67,9 @@ export const unitShippingAddressConfig: MyCompanyConfig = { export const userRolesConfig: MyCompanyConfig = { rows: [ { - label: 'Roles', + formLabel: 'Roles', updateValue: 'Manager', + inputType: INPUT_TYPE.CHECKBOX, }, ], }; @@ -78,8 +79,6 @@ export const unitConfig: MyCompanyConfig = { baseUrl: `${FULL_BASE_URL_EN_USD}/organization/units`, apiEndpoint: '/orgUnits', objectType: 'children', - nestedTableRows: true, - canDisable: true, verifyStatusInDetails: true, rows: [ { @@ -223,4 +222,11 @@ export const unitConfig: MyCompanyConfig = { createConfig: costCenterConfig, }, ], + features: [ + MY_COMPANY_FEATURE.CREATE, + MY_COMPANY_FEATURE.DISABLE, + MY_COMPANY_FEATURE.UPDATE, + MY_COMPANY_FEATURE.NESTED_LIST, + MY_COMPANY_FEATURE.ASSIGNMENTS, + ], }; diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/user-group.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/user-group.ts index 7f298a0b570..5e729832e14 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/user-group.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/user-group.ts @@ -1,6 +1,6 @@ import { FULL_BASE_URL_EN_USD } from '../../../site-context-selector'; import { randomString } from '../../../user'; -import { INPUT_TYPE, MyCompanyConfig } from '../models'; +import { INPUT_TYPE, MyCompanyConfig, MY_COMPANY_FEATURE } from '../models'; export const userGroupConfig: MyCompanyConfig = { name: 'User Group', @@ -62,4 +62,10 @@ export const userGroupConfig: MyCompanyConfig = { manageAssignments: true, }, ], + features: [ + MY_COMPANY_FEATURE.CREATE, + MY_COMPANY_FEATURE.UPDATE, + MY_COMPANY_FEATURE.LIST, + MY_COMPANY_FEATURE.ASSIGNMENTS, + ], }; diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/user.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/user.ts index 0d7c4b51044..50fa84335ca 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/user.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/config/user.ts @@ -14,7 +14,6 @@ export const userConfig: MyCompanyConfig = { objectType: 'users', entityIdField: 'customerId', preserveCookies: true, - canDisable: true, rows: [ { label: 'Name', @@ -36,7 +35,6 @@ export const userConfig: MyCompanyConfig = { updateValue: 'Mrs.', showInTable: false, }, - { label: 'First name', variableName: 'firstName', @@ -81,7 +79,7 @@ export const userConfig: MyCompanyConfig = { { label: 'Roles', variableName: 'roles', - formLabel: 'Roles', + inputType: INPUT_TYPE.CHECKBOX, createValue: 'Customer', updateValue: 'Manager', showInTable: true, @@ -138,5 +136,12 @@ export const userConfig: MyCompanyConfig = { manageAssignments: true, }, ], - features: [MY_COMPANY_FEATURE.USER_PASSWORD], + features: [ + MY_COMPANY_FEATURE.CREATE, + MY_COMPANY_FEATURE.DISABLE, + MY_COMPANY_FEATURE.UPDATE, + MY_COMPANY_FEATURE.LIST, + MY_COMPANY_FEATURE.ASSIGNMENTS, + MY_COMPANY_FEATURE.USER_PASSWORD, + ], }; diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-assign.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/assignments.ts similarity index 97% rename from projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-assign.ts rename to projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/assignments.ts index 6eba215003b..58c2265a553 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-assign.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/assignments.ts @@ -2,11 +2,14 @@ import { ASSIGNMENT_LABELS, CONFIRMATION_LABELS, MyCompanyConfig, -} from './models/index'; -import { completeForm, FormType } from './my-company-form'; -import { ignoreCaseSensivity, loginAsMyCompanyAdmin } from './my-company.utils'; +} from '../models/index'; +import { completeForm, FormType } from './utils/form'; +import { + ignoreCaseSensivity, + loginAsMyCompanyAdmin, +} from '../my-company.utils'; -export function testAssignmentFromConfig(config: MyCompanyConfig) { +export function assignmentsTest(config: MyCompanyConfig) { config?.subCategories?.forEach((subConfig: MyCompanyConfig) => { describe(`${config.name} Assignment - ${subConfig.name}`, () => { let firstOption: string; diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/create.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/create.ts new file mode 100644 index 00000000000..d1bde6bd423 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/create.ts @@ -0,0 +1,62 @@ +import { ENTITY_UID_COOKIE_KEY, MyCompanyConfig } from '../models/index'; +import { + ignoreCaseSensivity, + loginAsMyCompanyAdmin, +} from '../my-company.utils'; +import { completeForm, FormType, verifyDetails } from './utils/form'; + +export function createTest(config: MyCompanyConfig) { + describe(`${config.name} Create`, () => { + let entityUId: string; + let entityId: string; + + beforeEach(() => { + loginAsMyCompanyAdmin(); + + cy.visit(`${config.baseUrl}${entityId ? '/' + entityId : ''}`); + cy.route('GET', `**${config.apiEndpoint}**`).as('loadEntity'); + cy.get('cx-storefront').contains('Loading...').should('not.exist'); + cy.wait('@loadEntity'); + }); + + after(() => { + entityUId = undefined; + }); + + it(`should create`, () => { + if (config.selectOptionsEndpoint) { + cy.route(config.selectOptionsEndpoint).as('getSelectOptions'); + } + + cy.get(`cx-org-list a`).contains('Add').click(); + + cy.url().should('contain', `${config.baseUrl}/create`); + + cy.get('cx-org-form div.header h3').contains( + ignoreCaseSensivity(`Create ${config.name}`) + ); + + if (config.selectOptionsEndpoint) { + cy.wait('@getSelectOptions'); + } + completeForm(config.rows, FormType.CREATE); + + cy.route('POST', `**${config.apiEndpoint}**`).as('saveEntityData'); + cy.route('GET', `**${config.apiEndpoint}**`).as('loadEntityData'); + cy.get('div.header button').contains('Save').click(); + cy.wait('@saveEntityData').then((xhr) => { + entityUId = xhr.response.body[config.entityIdField]; + entityId = + entityUId ?? config.rows?.find((row) => row.useInUrl).createValue; + + if (config.preserveCookies) { + cy.setCookie(ENTITY_UID_COOKIE_KEY, entityUId); + } + + cy.wait('@loadEntityData'); + verifyDetails(config, FormType.CREATE); + cy.get('cx-org-card cx-icon[type="CLOSE"]').click(); + }); + }); + }); +} diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/disable.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/disable.ts new file mode 100644 index 00000000000..df6a741c7a1 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/disable.ts @@ -0,0 +1,76 @@ +import { CONFIRMATION_LABELS, MyCompanyConfig } from '../models/index'; +import { loginAsMyCompanyAdmin } from '../my-company.utils'; + +export function disableTest(config: MyCompanyConfig) { + describe(`${config.name} Disable`, () => { + let entityId: string; + const codeRow = config.rows?.find((row) => row.useInUrl || row.useCookie); + + before(() => { + loginAsMyCompanyAdmin(); + + cy.route('GET', `**${config.apiEndpoint}**`).as('getEntity'); + if (config.preserveCookies) { + cy.getCookie(codeRow.useCookie).then((cookie) => { + entityId = cookie.value; + cy.visit(`${config.baseUrl}/${entityId}`); + }); + } else { + entityId = codeRow.createValue; + cy.visit(`${config.baseUrl}/${entityId}`); + } + }); + + it('should disable/enable', () => { + cy.route('GET', `**${config.apiEndpoint}**`).as('loadEntity'); + cy.route('PATCH', `**`).as('saveEntity'); + + cy.get('cx-org-card div.header button').contains('Disable').click(); + cy.get('cx-org-confirmation') + .should( + 'contain.text', + `Are you sure you want to disable this ${config.name.toLowerCase()}?` + ) + .contains(CONFIRMATION_LABELS.CANCEL) + .click(); + cy.get('cx-org-confirmation').should('not.exist'); + + cy.get('div.header button').contains('Disable').click(); + cy.get('cx-org-confirmation') + .should( + 'contain.text', + `Are you sure you want to disable this ${config.name.toLowerCase()}?` + ) + .contains(CONFIRMATION_LABELS.CONFIRM) + .click(); + cy.wait('@saveEntity'); + cy.wait('@loadEntity'); + + cy.get('cx-org-confirmation').should('not.exist'); + cy.get('cx-org-notification').should('not.exist'); + cy.get('div.header button').contains('Disable').should('not.exist'); + + if (config.verifyStatusInDetails) { + cy.get('section.details label') + .contains('Status') + .parent() + .should('contain.text', 'Disabled'); + } + + cy.get('div.header button').contains('Enable').click(); + cy.wait('@saveEntity'); + cy.wait('@loadEntity'); + cy.get('cx-org-notification').should('not.exist'); + + cy.get('div.header button').contains('Enable').should('not.exist'); + cy.get('div.header button').contains('Disable').should('exist'); + + if (config.verifyStatusInDetails) { + cy.get('section.details label') + .contains('Status') + .parent() + .should('contain.text', 'Active'); + } + }); + }); +} diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/list.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/list.ts new file mode 100644 index 00000000000..527f2008e07 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/list.ts @@ -0,0 +1,22 @@ +import { MyCompanyConfig } from '../models/index'; +import { loginAsMyCompanyAdmin } from '../my-company.utils'; +import { testList, testListSorting } from './utils/list'; + +export function listTest(config: MyCompanyConfig): void { + describe(`${config.name} List`, () => { + beforeEach(() => { + loginAsMyCompanyAdmin(); + cy.server(); + }); + + it('should show and paginate list', () => { + cy.visit(`/organization`); + testList(config, { + trigger: () => + cy.get(`cx-page-slot.BodyContent a`).contains(config.name).click(), + }); + }); + + testListSorting(config); + }); +} diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/nested-list.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/nested-list.ts new file mode 100644 index 00000000000..8e067a4601c --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/nested-list.ts @@ -0,0 +1,30 @@ +import { MyCompanyConfig } from '../models/index'; +import { loginAsMyCompanyAdmin } from '../my-company.utils'; +import { testList } from './utils/list'; + +export function nestedListTest(config: MyCompanyConfig): void { + describe(`${config.name} Nested List`, () => { + beforeEach(() => { + loginAsMyCompanyAdmin(); + cy.server(); + }); + + it('should show expanded nested list', () => { + cy.visit(`/organization`); + testList(config, { + trigger: () => + cy.get(`cx-page-slot.BodyContent a`).contains(config.name).click(), + nested: { expandAll: true }, + }); + }); + + it('should show collapsed nested list', () => { + cy.visit(`/organization`); + testList(config, { + trigger: () => + cy.get(`cx-page-slot.BodyContent a`).contains(config.name).click(), + nested: { collapseAll: true }, + }); + }); + }); +} diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/update.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/update.ts new file mode 100644 index 00000000000..63dbd20d63e --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/update.ts @@ -0,0 +1,57 @@ +import { MyCompanyConfig } from '../models/index'; +import { + ignoreCaseSensivity, + loginAsMyCompanyAdmin, +} from '../my-company.utils'; +import { completeForm, FormType, verifyDetails } from './utils/form'; + +export function updateTest(config: MyCompanyConfig) { + describe(`${config.name} Update`, () => { + let entityId: string; + + const codeRow = config.rows?.find((row) => row.useInUrl || row.useCookie); + + before(() => { + loginAsMyCompanyAdmin(); + + cy.route('GET', `**${config.apiEndpoint}**`).as('getEntity'); + if (config.preserveCookies) { + cy.getCookie(codeRow.useCookie).then((cookie) => { + entityId = cookie.value; + cy.visit(`${config.baseUrl}/${entityId}`); + }); + } else { + entityId = codeRow.createValue; + cy.visit(`${config.baseUrl}/${entityId}`); + } + }); + + it(`should update`, () => { + if (config.selectOptionsEndpoint) { + cy.route(config.selectOptionsEndpoint).as('getSelectOptions'); + } + + cy.get(`cx-org-card a.link`).contains('Edit').click(); + cy.url().should('contain', `${config.baseUrl}/${entityId}/edit`); + + cy.get('cx-org-form div.header h3').contains( + ignoreCaseSensivity(`Edit ${config.name}`) + ); + + if (config.selectOptionsEndpoint) { + cy.wait('@getSelectOptions'); + } + + cy.route('PATCH', `**`).as('saveEntityData'); + cy.route('GET', `**${config.apiEndpoint}**`).as('loadEntityData'); + completeForm(config.rows, FormType.UPDATE); + cy.get('div.header button').contains('Save').click(); + cy.wait('@saveEntityData'); + cy.wait('@loadEntityData'); + + verifyDetails(config, FormType.UPDATE); + + cy.get('cx-icon[type="CLOSE"]').click(); + }); + }); +} diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/user-password.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/user-password.ts index 49a53f13329..0f0487f7d1e 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/user-password.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/user-password.ts @@ -1,8 +1,8 @@ import { fillLoginForm, LoginUser } from '../../../auth-forms'; import { waitForPage } from '../../../checkout-flow'; import { INPUT_TYPE, MyCompanyConfig } from '../models'; -import { completeForm, FormType } from '../my-company-form'; import { loginAsMyCompanyAdmin } from '../my-company.utils'; +import { completeForm, FormType } from './utils/form'; export function userPasswordTest(config: MyCompanyConfig): void { const TEST_PASSWORD = 'tE5tP@$5'; diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/utils/form.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/utils/form.ts new file mode 100644 index 00000000000..a4c26b40095 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/utils/form.ts @@ -0,0 +1,158 @@ +import { + INPUT_TYPE, + MyCompanyConfig, + MyCompanyRowConfig, +} from '../../models/index'; +import { ignoreCaseSensivity } from '../../my-company.utils'; + +export enum FormType { + CREATE = 'create', + UPDATE = 'update', +} + +/** + * Returns the key of the MyCompanyRowConfig for the item value. + * + * Depending on the form type, it returns the key for creating OR updating value. + */ +export function getValueKey(formType: FormType): 'createValue' | 'updateValue' { + switch (formType) { + case FormType.CREATE: + return 'createValue'; + case FormType.UPDATE: + return 'updateValue'; + } +} + +export function completeForm( + rowConfigs: MyCompanyRowConfig[], + formType: FormType +) { + const valueKey = getValueKey(formType); + rowConfigs.forEach((input) => { + if (input.formLabel) { + getFieldByLabel(input).then((el) => { + if (!el.html().includes('disabled')) { + switch (input.inputType) { + case INPUT_TYPE.TEXT: + return fillTextInput(input); + case INPUT_TYPE.DATE_TIME: + return fillDateTimePicker(input); + case INPUT_TYPE.DATE: + return fillTextInput(input); + case INPUT_TYPE.NG_SELECT: + return fillNgSelect(input); + case INPUT_TYPE.CHECKBOX: + return selectCheckbox(input); + } + } + }); + } + }); + + function getFieldByLabel(input: MyCompanyRowConfig) { + return input.inputType === INPUT_TYPE.CHECKBOX + ? cy.get('fieldset legend').contains(input.formLabel).parent() + : cy.get('label span').contains(input.formLabel).parent(); + } + + // For situations where more than one control exists in form with the same label. + function getFieldBySelector(selector: string) { + return cy.get(selector); + } + + function fillTextInput(input: MyCompanyRowConfig): void { + if (input.selector) { + getFieldBySelector(input.selector).clear().type(input[valueKey]); + } else { + getFieldByLabel(input).within(() => { + cy.get(`input`).clear().type(input[valueKey]); + }); + } + } + + function fillDateTimePicker(input: MyCompanyRowConfig) { + if (input.selector) { + getFieldBySelector(input.selector).clear().type(input[valueKey]); + } else { + getFieldByLabel(input).within(() => { + cy.get(`cx-date-time-picker input`).clear().type(input[valueKey]); + }); + } + } + + function fillNgSelect(input: MyCompanyRowConfig) { + // First check if `valueKey` is defined. For example select should be omitted if `updateValue` is empty. + if (input[valueKey]) { + getFieldByLabel(input).within(() => { + cy.get(`ng-select`).click(); + }); + cy.wait(1000); // Allow time for options to draw + cy.get('ng-dropdown-panel') + .contains(input[valueKey]) + .click({ force: true }); + } + } + + function selectCheckbox(input) { + getFieldByLabel(input).within(() => { + cy.get('[type="checkbox"]').check(input[valueKey]); + }); + } +} + +export function verifyDetails(config: MyCompanyConfig, formType: FormType) { + const valueKey = getValueKey(formType); + + const codeRow = config.rows?.find((row) => row.useInUrl); + const headerRows = config.rows?.filter((row) => row.useInHeader); + + if (codeRow) { + cy.url().should('contain', `${config.baseUrl}/${codeRow[valueKey]}`); + } + + cy.get('cx-org-card div.header h3').contains( + ignoreCaseSensivity(`${config.name} Details`) + ); + + headerRows.forEach((hRow) => { + cy.get('cx-org-card div.header h4').contains( + ignoreCaseSensivity(hRow[valueKey]) + ); + }); + + config.rows.forEach((rowConfig) => { + if (rowConfig.showInDetails) { + const label = rowConfig.detailsLabel || rowConfig.label; + + cy.get('div.property label').should('contain.text', label); + cy.get('div.property').should( + 'contain.text', + rowConfig[valueKey] || label + ); + + const link = getLink(rowConfig); + if (link) { + // TODO: spike todo get context from variables + cy.get('div.property a').should( + 'have.attr', + 'href', + `/powertools-spa/en/USD${link}` + ); + } + } + }); + + /** + * Returns the link for the given row. + * + * - For FormType.CREATE, it returns `link` + * - For FormType.UPDATE, it returns `updatedLink` or `link` (if `updatedLink` is not present) + */ + function getLink(rowConfig: MyCompanyRowConfig): string | undefined { + if (rowConfig.updatedLink && formType === FormType.UPDATE) { + return rowConfig.updatedLink; + } + return rowConfig.link; + } +} diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-list.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/utils/list.ts similarity index 84% rename from projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-list.ts rename to projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/utils/list.ts index cefe9997691..cb0254d69f2 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-list.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/features/utils/list.ts @@ -4,53 +4,11 @@ import { MyCompanyConfig, MyCompanyRowConfig, TestListOptions, -} from './models/index'; -import { loginAsMyCompanyAdmin, waitForData } from './my-company.utils'; +} from '../../models'; +import { waitForData } from '../../my-company.utils'; let requestData: any; -export function testListFromConfig(config: MyCompanyConfig): void { - if (!config.disableListChecking) { - describe(`${config.name} List`, () => { - beforeEach(() => { - loginAsMyCompanyAdmin(); - cy.server(); - }); - - if (!config.nestedTableRows) { - it('should show and paginate list', () => { - cy.visit(`/organization`); - testList(config, { - trigger: () => - cy - .get(`cx-page-slot.BodyContent a`) - .contains(config.name) - .click(), - }); - }); - - testListSorting(config); - } else { - it('should show expanded nested list', () => { - cy.visit(`/organization`); - testList(config, { - trigger: () => - cy - .get(`cx-page-slot.BodyContent a`) - .contains(config.name) - .click(), - nested: { expandAll: true }, - }); - }); - - it('should show collapsed nested list', () => { - testList(config, { nested: { collapseAll: true } }); - }); - } - }); - } -} - export function testList( config: MyCompanyConfig, options?: TestListOptions @@ -58,7 +16,7 @@ export function testList( cy.route('GET', `**${config.apiEndpoint}**`).as('getData'); if (options.trigger) { waitForData((data) => { - requestData = data; + const requestData = data; validateList(requestData); }, options.trigger()); } else { diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/models/my-company.config.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/models/my-company.config.ts index 6b329fb9264..30ceef51ed0 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/models/my-company.config.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/models/my-company.config.ts @@ -106,16 +106,6 @@ export interface MyCompanyConfig { */ entityIdField?: string; - /** - * Test list utilizing a nested tree ux. - */ - nestedTableRows?: boolean; - - /** - * Set to true if checking list features for such config is not needed. - */ - disableListChecking?: boolean; - /** * Configuration of preserve cookies value. */ @@ -152,11 +142,6 @@ export interface MyCompanyConfig { */ rolesConfig?: MyCompanyConfig; - /** - * Set to true to check disabling and enabling an entity - */ - canDisable?: boolean; - /** * Set to true to check status in details pane. */ diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/models/my-company.model.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/models/my-company.model.ts index cd177af951f..d1fed4ee567 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/models/my-company.model.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/models/my-company.model.ts @@ -33,6 +33,12 @@ export interface TestListOptions { } export enum MY_COMPANY_FEATURE { + LIST = 'list', + NESTED_LIST = 'nestedList', + CREATE = 'create', + UPDATE = 'update', + DISABLE = 'disable', + ASSIGNMENTS = 'assignments', USER_PASSWORD = 'userPassword', } diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-features.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-features.ts index 6142fc7debe..ce22fb2cb6f 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-features.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-features.ts @@ -1,7 +1,19 @@ +import { assignmentsTest } from './features/assignments'; +import { createTest } from './features/create'; +import { disableTest } from './features/disable'; +import { listTest } from './features/list'; +import { nestedListTest } from './features/nested-list'; +import { updateTest } from './features/update'; import { userPasswordTest } from './features/user-password'; import { MyCompanyConfig, MY_COMPANY_FEATURE } from './models'; const testMapping = { + [MY_COMPANY_FEATURE.LIST]: listTest, + [MY_COMPANY_FEATURE.NESTED_LIST]: nestedListTest, + [MY_COMPANY_FEATURE.CREATE]: createTest, + [MY_COMPANY_FEATURE.UPDATE]: updateTest, + [MY_COMPANY_FEATURE.DISABLE]: disableTest, + [MY_COMPANY_FEATURE.ASSIGNMENTS]: assignmentsTest, [MY_COMPANY_FEATURE.USER_PASSWORD]: userPasswordTest, }; diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-form.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-form.ts deleted file mode 100644 index 5fa5ffdb902..00000000000 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company-form.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { - CONFIRMATION_LABELS, - ENTITY_UID_COOKIE_KEY, - INPUT_TYPE, - MyCompanyConfig, - MyCompanyRowConfig, -} from './models/index'; -import { ignoreCaseSensivity, loginAsMyCompanyAdmin } from './my-company.utils'; - -export enum FormType { - CREATE = 'create', - UPDATE = 'update', -} - -/** - * Returns the key of the MyCompanyRowConfig for the item value. - * - * Depending on the form type, it returns the key for creating OR updating value. - */ -function getValueKey(formType: FormType): 'createValue' | 'updateValue' { - switch (formType) { - case FormType.CREATE: - return 'createValue'; - case FormType.UPDATE: - return 'updateValue'; - } -} - -export function testCreateUpdateFromConfig(config: MyCompanyConfig) { - describe(`${config.name} Create / Update`, () => { - let entityUId: string; - let entityId: string; - - beforeEach(() => { - loginAsMyCompanyAdmin(); - - cy.visit(`${config.baseUrl}${entityId ? '/' + entityId : ''}`); - cy.route('GET', `**${config.apiEndpoint}**`).as('loadEntity'); - cy.get('cx-storefront').contains('Loading...').should('not.exist'); - cy.wait('@loadEntity'); - }); - - after(() => { - entityUId = undefined; - }); - - it(`should create`, () => { - if (config.selectOptionsEndpoint) { - cy.route(config.selectOptionsEndpoint).as('getSelectOptions'); - } - - cy.get(`cx-org-list a`).contains('Add').click(); - - cy.url().should('contain', `${config.baseUrl}/create`); - - cy.get('cx-org-form div.header h3').contains( - ignoreCaseSensivity(`Create ${config.name}`) - ); - - if (config.selectOptionsEndpoint) { - cy.wait('@getSelectOptions'); - } - completeForm(config.rows, FormType.CREATE); - - cy.route('POST', `**${config.apiEndpoint}**`).as('saveEntityData'); - cy.route('GET', `**${config.apiEndpoint}**`).as('loadEntityData'); - cy.get('div.header button').contains('Save').click(); - cy.wait('@saveEntityData').then((xhr) => { - entityUId = xhr.response.body[config.entityIdField]; - entityId = - entityUId ?? config.rows?.find((row) => row.useInUrl).createValue; - - if (config.preserveCookies) { - cy.setCookie(ENTITY_UID_COOKIE_KEY, entityUId); - } - - cy.wait('@loadEntityData'); - verifyDetails(config, FormType.CREATE); - cy.get('cx-org-card cx-icon[type="CLOSE"]').click(); - }); - }); - - if (config.canDisable) { - it('should disable/enable', () => { - cy.route('GET', `**${config.apiEndpoint}**`).as('loadEntity'); - cy.route('PATCH', `**`).as('saveEntity'); - - cy.get('cx-org-card div.header button').contains('Disable').click(); - cy.get('cx-org-confirmation') - .should( - 'contain.text', - `Are you sure you want to disable this ${config.name.toLowerCase()}?` - ) - .contains(CONFIRMATION_LABELS.CANCEL) - .click(); - cy.get('cx-org-confirmation').should('not.exist'); - - cy.get('div.header button').contains('Disable').click(); - cy.get('cx-org-confirmation') - .should( - 'contain.text', - `Are you sure you want to disable this ${config.name.toLowerCase()}?` - ) - .contains(CONFIRMATION_LABELS.CONFIRM) - .click(); - cy.wait('@saveEntity'); - cy.wait('@loadEntity'); - - cy.get('cx-org-confirmation').should('not.exist'); - cy.get('cx-org-notification').should('not.exist'); - cy.get('div.header button').contains('Disable').should('not.exist'); - - if (config.verifyStatusInDetails) { - cy.get('section.details label') - .contains('Status') - .parent() - .should('contain.text', 'Disabled'); - } - - cy.get('div.header button').contains('Enable').click(); - cy.wait('@saveEntity'); - cy.wait('@loadEntity'); - cy.get('cx-org-notification').should('not.exist'); - - cy.get('div.header button').contains('Enable').should('not.exist'); - cy.get('div.header button').contains('Disable').should('exist'); - - if (config.verifyStatusInDetails) { - cy.get('section.details label') - .contains('Status') - .parent() - .should('contain.text', 'Active'); - } - }); - } - - it(`should update`, () => { - if (config.selectOptionsEndpoint) { - cy.route(config.selectOptionsEndpoint).as('getSelectOptions'); - } - - cy.get(`cx-org-card a.link`).contains('Edit').click(); - cy.url().should('contain', `${config.baseUrl}/${entityId}/edit`); - - cy.get('cx-org-form div.header h3').contains( - ignoreCaseSensivity(`Edit ${config.name}`) - ); - - if (config.selectOptionsEndpoint) { - cy.wait('@getSelectOptions'); - } - - cy.route('PATCH', `**`).as('saveEntityData'); - cy.route('GET', `**${config.apiEndpoint}**`).as('loadEntityData'); - completeForm(config.rows, FormType.UPDATE); - cy.get('div.header button').contains('Save').click(); - cy.wait('@saveEntityData'); - cy.wait('@loadEntityData'); - - verifyDetails(config, FormType.UPDATE); - - cy.get('cx-icon[type="CLOSE"]').click(); - }); - }); -} - -export function completeForm( - rowConfigs: MyCompanyRowConfig[], - formType: FormType -) { - const valueKey = getValueKey(formType); - - rowConfigs.forEach((input) => { - if (input.formLabel) { - getFieldByLabel(input.formLabel).then((el) => { - if (!el.html().includes('disabled')) { - switch (input.inputType) { - case INPUT_TYPE.TEXT: - return fillTextInput(input); - case INPUT_TYPE.DATE_TIME: - return fillDateTimePicker(input); - case INPUT_TYPE.DATE: - return fillTextInput(input); - case INPUT_TYPE.NG_SELECT: - return fillNgSelect(input); - case INPUT_TYPE.CHECKBOX: - return selectCheckbox(input); - } - } - }); - } - }); - - function getFieldByLabel(label: string) { - return cy.get('label span').contains(label).parent(); - } - - // For situations where more than one control exists in form with the same label. - function getFieldBySelector(selector: string) { - return cy.get(selector); - } - - function fillTextInput(input: MyCompanyRowConfig): void { - if (input.selector) { - getFieldBySelector(input.selector).clear().type(input[valueKey]); - } else { - getFieldByLabel(input.formLabel).within(() => { - cy.get(`input`).clear().type(input[valueKey]); - }); - } - } - - function fillDateTimePicker(input: MyCompanyRowConfig) { - if (input.selector) { - getFieldBySelector(input.selector).clear().type(input[valueKey]); - } else { - getFieldByLabel(input.formLabel).within(() => { - cy.get(`cx-date-time-picker input`).clear().type(input[valueKey]); - }); - } - } - - function fillNgSelect(input: MyCompanyRowConfig) { - // First check if `valueKey` is defined. For example select should be omitted if `updateValue` is empty. - if (input[valueKey]) { - getFieldByLabel(input.formLabel).within(() => { - cy.get(`ng-select`).click(); - }); - cy.wait(1000); // Allow time for options to draw - cy.get('ng-dropdown-panel') - .contains(input[valueKey]) - .click({ force: true }); - } - } - - function selectCheckbox(input) { - getFieldByLabel(input.formLabel).within(() => { - cy.get('[type="checkbox"]').check(input[valueKey]); - }); - } -} - -function verifyDetails(config: MyCompanyConfig, formType: FormType) { - const valueKey = getValueKey(formType); - - const codeRow = config.rows?.find((row) => row.useInUrl); - const headerRows = config.rows?.filter((row) => row.useInHeader); - - if (codeRow) { - cy.url().should('contain', `${config.baseUrl}/${codeRow[valueKey]}`); - } - - cy.get('cx-org-card div.header h3').contains( - ignoreCaseSensivity(`${config.name} Details`) - ); - - headerRows.forEach((hRow) => { - cy.get('cx-org-card div.header h4').contains( - ignoreCaseSensivity(hRow[valueKey]) - ); - }); - - config.rows.forEach((rowConfig) => { - if (rowConfig.showInDetails) { - const label = rowConfig.detailsLabel || rowConfig.label; - - cy.get('div.property label').should('contain.text', label); - cy.get('div.property').should( - 'contain.text', - rowConfig[valueKey] || label - ); - - const link = getLink(rowConfig); - if (link) { - // TODO: spike todo get context from variables - cy.get('div.property a').should( - 'have.attr', - 'href', - `/powertools-spa/en/USD${link}` - ); - } - } - }); - - /** - * Returns the link for the given row. - * - * - For FormType.CREATE, it returns `link` - * - For FormType.UPDATE, it returns `updatedLink` or `link` (if `updatedLink` is not present) - */ - function getLink(rowConfig: MyCompanyRowConfig): string | undefined { - if (rowConfig.updatedLink && formType === FormType.UPDATE) { - return rowConfig.updatedLink; - } - return rowConfig.link; - } -} diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company.utils.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company.utils.ts index 99a86dd83f8..b52a5eb1e9c 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company.utils.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/b2b/my-company/my-company.utils.ts @@ -1,8 +1,4 @@ import { ENTITY_UID_COOKIE_KEY, MyCompanyConfig } from './models/index'; -import { testListFromConfig } from './my-company-list'; -import { testCreateUpdateFromConfig } from './my-company-form'; -import { testAssignmentFromConfig } from './my-company-assign'; -import { nextPage } from '../../product-search'; import { POWERTOOLS_BASESITE } from '../../../sample-data/b2b-checkout'; import { myCompanyAdminUser } from '../../../sample-data/shared-users'; import { testFeaturesFromConfig } from './my-company-features'; @@ -25,9 +21,6 @@ export function testMyCompanyFeatureFromConfig(config: MyCompanyConfig) { cy.saveLocalStorage(); }); - testListFromConfig(config); - testCreateUpdateFromConfig(config); - testAssignmentFromConfig(config); testFeaturesFromConfig(config); }); } @@ -50,29 +43,6 @@ export function loginAsMyCompanyAdmin(): void { cy.requireLoggedIn(myCompanyAdminUser); } -export function scanTablePagesForText( - text: string, - config: MyCompanyConfig -): void { - cy.get('cx-table').then(($table) => { - // For table in tree mode expand all elements first and find editable one. - if (config.nestedTableRows) { - cy.get('cx-org-list div.header button').contains('Expand all').click(); - } - - if ($table.text().indexOf(text) === -1) { - cy.server(); - cy.route('GET', `**/${config.apiEndpoint}**`).as('getData'); - // Do not use pagination check for tables in tree mode. - if (!config.nestedTableRows) { - nextPage(); - cy.wait('@getData'); - scanTablePagesForText(text, config); - } - } - }); -} - /** * Converts string value to RegExp ignoring case sensivity. */ diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/units.flaky-e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/units.e2e-spec.ts similarity index 100% rename from projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/units.flaky-e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/units.e2e-spec.ts diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/users.flaky-e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/users.e2e-spec.ts similarity index 100% rename from projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/users.flaky-e2e-spec.ts rename to projects/storefrontapp-e2e-cypress/cypress/integration/b2b/regression/my-company/users.e2e-spec.ts From cecd2ec9c90faf323fa63e34c94aea3c1c0faad7 Mon Sep 17 00:00:00 2001 From: Marcin Lasak Date: Thu, 21 Jan 2021 10:14:02 +0100 Subject: [PATCH 14/30] chore: Handle not analyzable new entry points in api-extractor (#10802) --- .github/api-extractor-action/src/comment.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/api-extractor-action/src/comment.ts b/.github/api-extractor-action/src/comment.ts index 27cacd1dbbf..a335ebde625 100644 --- a/.github/api-extractor-action/src/comment.ts +++ b/.github/api-extractor-action/src/comment.ts @@ -118,7 +118,7 @@ function extractListOfNotAnalyzedEntryPoints( .filter((entryPoint) => { return ( entryPoint.head.status === Status.Failed && - entryPoint.base.status === Status.Failed + entryPoint.base.status !== Status.Success ); }) .map((entryPoints) => { @@ -217,12 +217,24 @@ New error: \`${entry.head.errors[0]}\``; } } else if (entry.head.status === Status.Unknown) { return `### :boom: ${entry.name}\nEntry point removed. Are you sure it was intentional?`; - } else if (entry.base.status === Status.Unknown) { + } else if ( + entry.base.status === Status.Unknown && + entry.head.status === Status.Success + ) { const publicApi = extractSnippetFromFile(`${REPORT_DIR}/${entry.file}`); return `### :warning: ${entry.name} New entry point. Initial public api: \`\`\`ts ${publicApi} +\`\`\``; + } else if ( + entry.base.status === Status.Unknown && + entry.head.status === Status.Failed + ) { + return `### :boom: ${entry.name} +New entry point that can't be analyzed with api-extractor. Please check the errors: +\`\`\` +${entry.head.errors.join('\n')} \`\`\``; } return ''; From 128d4d517e0d4813fbc18fedc4207128bf4880a5 Mon Sep 17 00:00:00 2001 From: Brian Gamboc-Javiniar Date: Fri, 22 Jan 2021 16:38:53 -0500 Subject: [PATCH 15/30] feat: Epic/qualtrics embedded component (#10805) * Introduce Qualtrics feature library + new component (GH-10309) (#10578) * placeholder before final feature lib for qualtrics * added to angular.json - still not linked * connected feature-lib * added deprecation, moved everything to lib, and tried adding schematics support * added qualtrics lib to other configs * remove optionall i18n from feature-config * fix feature-lib * ng-package for components * removed ng-bootstrap and add some dependency * added comma in missing ng-package.json * rename old lib with alias as deprecated qualtrics component * fixed import * pretiffied files * added attribute to ts.lcov property * added the empty jest file * addex fix from code review * files that did not get added in due to inner folder * include bootstrap files * addressed code review comments * simplified styling usage * revert script and env * fix version for release doc * fix: Handle subscriptions in Qualtrics Loader Service and update the isDataLoaded mechanism #10691 (#10735) * commit to test branch * revert test changes * update qualtrics test * did code review changes * chore: revert alias naming (#10815) * revert alias naming * chang ein cms-lib too * test checks again with empty commit --- .github/ISSUE_TEMPLATE/new-release.md | 1 + angular.json | 42 ++++ ci-scripts/unit-tests-sonar.sh | 12 ++ ci-scripts/validate-lint.sh | 1 + feature-libs/qualtrics/.release-it.json | 34 +++ feature-libs/qualtrics/README.md | 6 + feature-libs/qualtrics/_index.scss | 6 + .../qualtrics/components/ng-package.json | 9 + .../qualtrics/components/public_api.ts | 3 + .../components/qualtrics-components.module.ts | 27 +++ ...ltrics-embedded-feedback.component.spec.ts | 23 ++ .../qualtrics-embedded-feedback.component.ts | 7 + .../config/default-qualtrics-config.ts | 7 + .../config/qualtrics-config.ts | 25 +++ .../components/qualtrics-loader/index.ts | 3 + .../qualtrics-loader.service.spec.ts | 154 ++++++++++++++ .../qualtrics-loader.service.ts | 142 +++++++++++++ .../qualtrics.component.spec.ts | 78 +++++++ .../qualtrics-loader/qualtrics.component.ts | 25 +++ .../qualtrics/jest.schematics.config.js | 29 +++ feature-libs/qualtrics/jest.ts | 24 +++ feature-libs/qualtrics/karma.conf.js | 40 ++++ feature-libs/qualtrics/ng-package.json | 12 ++ feature-libs/qualtrics/package.json | 37 ++++ feature-libs/qualtrics/public_api.ts | 5 + feature-libs/qualtrics/qualtrics.module.ts | 7 + feature-libs/qualtrics/root/ng-package.json | 9 + feature-libs/qualtrics/root/public_api.ts | 1 + .../qualtrics/root/qualtrics-root.module.ts | 15 ++ feature-libs/qualtrics/schematics/.gitignore | 18 ++ .../schematics/add-qualtrics/index.ts | 74 +++++++ .../schematics/add-qualtrics/index_spec.ts | 199 ++++++++++++++++++ .../schematics/add-qualtrics/schema.json | 21 ++ .../qualtrics/schematics/collection.json | 13 ++ feature-libs/qualtrics/styles/_index.scss | 1 + .../styles/_qualtrics-embedded-feedback.scss | 4 + feature-libs/qualtrics/test.ts | 35 +++ feature-libs/qualtrics/tsconfig.lib.json | 28 +++ feature-libs/qualtrics/tsconfig.lib.prod.json | 6 + .../qualtrics/tsconfig.schematics.json | 28 +++ feature-libs/qualtrics/tsconfig.spec.json | 10 + feature-libs/qualtrics/tslint.json | 8 + package.json | 6 +- projects/schematics/package.json | 1 + projects/schematics/src/shared/constants.ts | 9 + .../src/environments/b2c/b2c.feature.ts | 12 +- .../src/styles/lib-qualtrics.scss | 1 + projects/storefrontapp/tsconfig.app.prod.json | 3 + projects/storefrontapp/tsconfig.server.json | 7 + .../storefrontapp/tsconfig.server.prod.json | 3 + .../config/default-qualtrics-config.ts | 7 +- .../qualtrics/qualtrics-loader.service.ts | 55 ++--- .../misc/qualtrics/qualtrics.component.ts | 5 + .../misc/qualtrics/qualtrics.module.ts | 6 + scripts/changelog.ts | 5 + scripts/install/config.default.sh | 1 + scripts/packages.ts | 1 + scripts/templates/changelog.ejs | 1 + sonar-project.properties | 4 +- tsconfig.compodoc.json | 9 + tsconfig.json | 9 + 61 files changed, 1341 insertions(+), 33 deletions(-) create mode 100644 feature-libs/qualtrics/.release-it.json create mode 100644 feature-libs/qualtrics/README.md create mode 100644 feature-libs/qualtrics/_index.scss create mode 100644 feature-libs/qualtrics/components/ng-package.json create mode 100644 feature-libs/qualtrics/components/public_api.ts create mode 100644 feature-libs/qualtrics/components/qualtrics-components.module.ts create mode 100644 feature-libs/qualtrics/components/qualtrics-embedded-feedback/qualtrics-embedded-feedback.component.spec.ts create mode 100644 feature-libs/qualtrics/components/qualtrics-embedded-feedback/qualtrics-embedded-feedback.component.ts create mode 100644 feature-libs/qualtrics/components/qualtrics-loader/config/default-qualtrics-config.ts create mode 100644 feature-libs/qualtrics/components/qualtrics-loader/config/qualtrics-config.ts create mode 100644 feature-libs/qualtrics/components/qualtrics-loader/index.ts create mode 100644 feature-libs/qualtrics/components/qualtrics-loader/qualtrics-loader.service.spec.ts create mode 100644 feature-libs/qualtrics/components/qualtrics-loader/qualtrics-loader.service.ts create mode 100644 feature-libs/qualtrics/components/qualtrics-loader/qualtrics.component.spec.ts create mode 100644 feature-libs/qualtrics/components/qualtrics-loader/qualtrics.component.ts create mode 100644 feature-libs/qualtrics/jest.schematics.config.js create mode 100644 feature-libs/qualtrics/jest.ts create mode 100644 feature-libs/qualtrics/karma.conf.js create mode 100644 feature-libs/qualtrics/ng-package.json create mode 100644 feature-libs/qualtrics/package.json create mode 100644 feature-libs/qualtrics/public_api.ts create mode 100644 feature-libs/qualtrics/qualtrics.module.ts create mode 100644 feature-libs/qualtrics/root/ng-package.json create mode 100644 feature-libs/qualtrics/root/public_api.ts create mode 100644 feature-libs/qualtrics/root/qualtrics-root.module.ts create mode 100644 feature-libs/qualtrics/schematics/.gitignore create mode 100644 feature-libs/qualtrics/schematics/add-qualtrics/index.ts create mode 100644 feature-libs/qualtrics/schematics/add-qualtrics/index_spec.ts create mode 100644 feature-libs/qualtrics/schematics/add-qualtrics/schema.json create mode 100644 feature-libs/qualtrics/schematics/collection.json create mode 100644 feature-libs/qualtrics/styles/_index.scss create mode 100644 feature-libs/qualtrics/styles/_qualtrics-embedded-feedback.scss create mode 100644 feature-libs/qualtrics/test.ts create mode 100644 feature-libs/qualtrics/tsconfig.lib.json create mode 100644 feature-libs/qualtrics/tsconfig.lib.prod.json create mode 100644 feature-libs/qualtrics/tsconfig.schematics.json create mode 100644 feature-libs/qualtrics/tsconfig.spec.json create mode 100644 feature-libs/qualtrics/tslint.json create mode 100644 projects/storefrontapp/src/styles/lib-qualtrics.scss diff --git a/.github/ISSUE_TEMPLATE/new-release.md b/.github/ISSUE_TEMPLATE/new-release.md index d5b5be1ac6c..101e2e6a35d 100644 --- a/.github/ISSUE_TEMPLATE/new-release.md +++ b/.github/ISSUE_TEMPLATE/new-release.md @@ -82,6 +82,7 @@ To keep track of spartacussampledata releases, we keep a `latest` branch on each - [ ] `npm run release:setup:with-changelog` (needed since `3.0.0-next.1`) - [ ] `npm run release:organization:with-changelog` (needed since `3.0.0-next.1`) - [ ] `npm run release:storefinder:with-changelog` (needed since `3.0.0-rc.0`) + - [ ] `npm run release:qualtrics:with-changelog` (needed since `3.1.0`) - [ ] `npm run release:cdc:with-changelog` (since 2.1.0-next.0 - publish under `0..0` eg. `0.201.0-next.0` for first `2.1.0-next.0` release) - [ ] before the script set the spartacus peerDependencies manually (as we publish it under 0.201.0-next.0 version) - [ ] Check that the release notes are populated on github (if they are not, update them) diff --git a/angular.json b/angular.json index 772cf5b4283..59fdb584089 100644 --- a/angular.json +++ b/angular.json @@ -41,6 +41,10 @@ { "input": "projects/storefrontapp/src/styles/lib-storefinder.scss", "bundleName": "storefinder" + }, + { + "input": "projects/storefrontapp/src/styles/lib-qualtrics.scss", + "bundleName": "qualtrics" } ], "stylePreprocessorOptions": { @@ -600,6 +604,44 @@ } } } + }, + "qualtrics": { + "projectType": "library", + "root": "feature-libs/qualtrics", + "sourceRoot": "feature-libs/qualtrics", + "prefix": "cx", + "architect": { + "build": { + "builder": "@angular-devkit/build-ng-packagr:build", + "options": { + "tsConfig": "feature-libs/qualtrics/tsconfig.lib.json", + "project": "feature-libs/qualtrics/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "feature-libs/qualtrics/tsconfig.lib.prod.json" + } + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "feature-libs/qualtrics/test.ts", + "tsConfig": "feature-libs/qualtrics/tsconfig.spec.json", + "karmaConfig": "feature-libs/qualtrics/karma.conf.js" + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "feature-libs/qualtrics/tsconfig.lib.json", + "feature-libs/qualtrics/tsconfig.spec.json" + ], + "exclude": ["**/node_modules/**"] + } + } + } } }, "defaultProject": "storefrontapp" diff --git a/ci-scripts/unit-tests-sonar.sh b/ci-scripts/unit-tests-sonar.sh index 0a82dae6eaa..6ccf6c95f76 100755 --- a/ci-scripts/unit-tests-sonar.sh +++ b/ci-scripts/unit-tests-sonar.sh @@ -64,6 +64,18 @@ echo "Running schematics unit tests and code coverage for storefinder library" exec 5>&1 output=$(yarn --cwd feature-libs/storefinder run test:schematics --coverage=true | tee /dev/fd/5) +echo "Running unit tests and code coverage for qualtrics library" +exec 5>&1 +output=$(ng test qualtrics --sourceMap --watch=false --code-coverage --browsers=ChromeHeadless | tee /dev/fd/5) +coverage=$(echo $output | grep -i "does not meet global threshold" || true) +if [[ -n "$coverage" ]]; then + echo "Error: Tests did not meet coverage expectations" + exit 1 +fi +echo "Running schematics unit tests and code coverage for qualtrics library" +exec 5>&1 +output=$(yarn --cwd feature-libs/qualtrics run test:schematics --coverage=true | tee /dev/fd/5) + echo "Running unit tests and code coverage for setup" exec 5>&1 output=$(ng test setup --sourceMap --watch=false --code-coverage --browsers=ChromeHeadless | tee /dev/fd/5) diff --git a/ci-scripts/validate-lint.sh b/ci-scripts/validate-lint.sh index f0548fbfbf8..0d64fa8d095 100755 --- a/ci-scripts/validate-lint.sh +++ b/ci-scripts/validate-lint.sh @@ -72,6 +72,7 @@ echo "Cleaning schematics js files before prettier runs..." yarn --cwd projects/schematics run clean yarn --cwd feature-libs/organization run clean:schematics yarn --cwd feature-libs/storefinder run clean:schematics +yarn --cwd feature-libs/qualtrics run clean:schematics echo "Validating code formatting (using prettier)" yarn prettier 2>&1 | tee prettier.log diff --git a/feature-libs/qualtrics/.release-it.json b/feature-libs/qualtrics/.release-it.json new file mode 100644 index 00000000000..33843c33249 --- /dev/null +++ b/feature-libs/qualtrics/.release-it.json @@ -0,0 +1,34 @@ +{ + "git": { + "requireCleanWorkingDir": true, + "requireUpstream": false, + "tagName": "qualtrics-${version}", + "commitMessage": "Bumping qualtrics version to ${version}", + "tagAnnotation": "Bumping qualtrics version to ${version}" + }, + "npm": { + "publishPath": "./../../dist/qualtrics" + }, + "hooks": { + "after:version:bump": "cd ../.. && yarn build:qualtrics" + }, + "github": { + "release": true, + "assets": ["../../docs.tar.gz", "../../docs.zip"], + "releaseName": "@spartacus/qualtrics@${version}", + "releaseNotes": "ts-node ../../scripts/changelog.ts --verbose --lib qualtrics --to qualtrics-${version}" + }, + "plugins": { + "../../scripts/release-it/bumper.js": { + "out": [ + { + "file": "package.json", + "path": [ + "peerDependencies.@spartacus/core", + "peerDependencies.@spartacus/schematics" + ] + } + ] + } + } +} diff --git a/feature-libs/qualtrics/README.md b/feature-libs/qualtrics/README.md new file mode 100644 index 00000000000..fc02740a015 --- /dev/null +++ b/feature-libs/qualtrics/README.md @@ -0,0 +1,6 @@ +# Spartacus Qualtrics + +Qualtrics can be added to the existing Spartacus application by running `ng add @spartacus/qualtrics`. For more information about Spartacus schematics, visit the [official Spartacus schematics documentation page](https://sap.github.io/spartacus-docs/schematics/). + + +For more information about Spartacus, see [Spartacus](https://github.com/SAP/spartacus). diff --git a/feature-libs/qualtrics/_index.scss b/feature-libs/qualtrics/_index.scss new file mode 100644 index 00000000000..81611564f83 --- /dev/null +++ b/feature-libs/qualtrics/_index.scss @@ -0,0 +1,6 @@ +// we require a few bootstrap files for the CSS in this code +@import '~bootstrap/scss/functions'; +@import '~bootstrap/scss/variables'; +@import '~bootstrap/scss/_mixins'; + +@import './styles/index'; diff --git a/feature-libs/qualtrics/components/ng-package.json b/feature-libs/qualtrics/components/ng-package.json new file mode 100644 index 00000000000..6ffef75176d --- /dev/null +++ b/feature-libs/qualtrics/components/ng-package.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts", + "umdModuleIds": { + "@spartacus/core": "core" + } + } +} diff --git a/feature-libs/qualtrics/components/public_api.ts b/feature-libs/qualtrics/components/public_api.ts new file mode 100644 index 00000000000..b0f96cde861 --- /dev/null +++ b/feature-libs/qualtrics/components/public_api.ts @@ -0,0 +1,3 @@ +export * from './qualtrics-components.module'; +export * from './qualtrics-embedded-feedback/qualtrics-embedded-feedback.component'; +export * from './qualtrics-loader/index'; diff --git a/feature-libs/qualtrics/components/qualtrics-components.module.ts b/feature-libs/qualtrics/components/qualtrics-components.module.ts new file mode 100644 index 00000000000..9cce81209c5 --- /dev/null +++ b/feature-libs/qualtrics/components/qualtrics-components.module.ts @@ -0,0 +1,27 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { CmsConfig, provideDefaultConfig } from '@spartacus/core'; +import { QualtricsEmbeddedFeedbackComponent } from './qualtrics-embedded-feedback/qualtrics-embedded-feedback.component'; +import { defaultQualtricsConfig } from './qualtrics-loader/config/default-qualtrics-config'; +import { QualtricsComponent } from './qualtrics-loader/qualtrics.component'; + +@NgModule({ + imports: [CommonModule], + providers: [ + provideDefaultConfig({ + cmsComponents: { + QualtricsEmbeddedFeedbackComponent: { + component: QualtricsEmbeddedFeedbackComponent, + }, + QualtricsComponent: { + component: QualtricsComponent, + }, + }, + }), + provideDefaultConfig(defaultQualtricsConfig), + ], + declarations: [QualtricsComponent, QualtricsEmbeddedFeedbackComponent], + exports: [QualtricsComponent, QualtricsEmbeddedFeedbackComponent], + entryComponents: [QualtricsComponent, QualtricsEmbeddedFeedbackComponent], +}) +export class QualtricsComponentsModule {} diff --git a/feature-libs/qualtrics/components/qualtrics-embedded-feedback/qualtrics-embedded-feedback.component.spec.ts b/feature-libs/qualtrics/components/qualtrics-embedded-feedback/qualtrics-embedded-feedback.component.spec.ts new file mode 100644 index 00000000000..74092e4e116 --- /dev/null +++ b/feature-libs/qualtrics/components/qualtrics-embedded-feedback/qualtrics-embedded-feedback.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { QualtricsEmbeddedFeedbackComponent } from './qualtrics-embedded-feedback.component'; + +describe('QualtricsEmbeddedFeedbackComponent', () => { + let component: QualtricsEmbeddedFeedbackComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [QualtricsEmbeddedFeedbackComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(QualtricsEmbeddedFeedbackComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/feature-libs/qualtrics/components/qualtrics-embedded-feedback/qualtrics-embedded-feedback.component.ts b/feature-libs/qualtrics/components/qualtrics-embedded-feedback/qualtrics-embedded-feedback.component.ts new file mode 100644 index 00000000000..4396282192f --- /dev/null +++ b/feature-libs/qualtrics/components/qualtrics-embedded-feedback/qualtrics-embedded-feedback.component.ts @@ -0,0 +1,7 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'cx-qualtrics-embedded-feedback', + template: '', +}) +export class QualtricsEmbeddedFeedbackComponent {} diff --git a/feature-libs/qualtrics/components/qualtrics-loader/config/default-qualtrics-config.ts b/feature-libs/qualtrics/components/qualtrics-loader/config/default-qualtrics-config.ts new file mode 100644 index 00000000000..8a075b55c30 --- /dev/null +++ b/feature-libs/qualtrics/components/qualtrics-loader/config/default-qualtrics-config.ts @@ -0,0 +1,7 @@ +import { QualtricsConfig } from './qualtrics-config'; + +export const defaultQualtricsConfig: QualtricsConfig = { + qualtrics: { + scriptSource: 'assets/qualtrics.js', + }, +}; diff --git a/feature-libs/qualtrics/components/qualtrics-loader/config/qualtrics-config.ts b/feature-libs/qualtrics/components/qualtrics-loader/config/qualtrics-config.ts new file mode 100644 index 00000000000..45dfe18dfed --- /dev/null +++ b/feature-libs/qualtrics/components/qualtrics-loader/config/qualtrics-config.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { Config } from '@spartacus/core'; + +/** + * Configuration options for the Qualtrics integration, which allows you to + * specify the qualtrics project and deployment script. + */ +@Injectable({ + providedIn: 'root', + useExisting: Config, +}) +export abstract class QualtricsConfig { + /** + * Holds the qualtrics integration options. + */ + qualtrics?: { + /** + * Deployment script, loaded from a resource, to integrate the deployment of the qualtrics project. + * You would typically store the file in the local assets folder. + * + * Defaults to `assets/qualtricsIntegration.js` + */ + scriptSource?: string; + }; +} diff --git a/feature-libs/qualtrics/components/qualtrics-loader/index.ts b/feature-libs/qualtrics/components/qualtrics-loader/index.ts new file mode 100644 index 00000000000..1df298d11ef --- /dev/null +++ b/feature-libs/qualtrics/components/qualtrics-loader/index.ts @@ -0,0 +1,3 @@ +export * from './config/qualtrics-config'; +export * from './qualtrics-loader.service'; +export * from './qualtrics.component'; diff --git a/feature-libs/qualtrics/components/qualtrics-loader/qualtrics-loader.service.spec.ts b/feature-libs/qualtrics/components/qualtrics-loader/qualtrics-loader.service.spec.ts new file mode 100644 index 00000000000..01174a4973b --- /dev/null +++ b/feature-libs/qualtrics/components/qualtrics-loader/qualtrics-loader.service.spec.ts @@ -0,0 +1,154 @@ +import { Injectable, RendererFactory2 } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { WindowRef } from '@spartacus/core'; +import { of } from 'rxjs'; +import { + QualtricsLoaderService, + QUALTRICS_EVENT_NAME, +} from './qualtrics-loader.service'; + +const mockQsiJsApi = { + API: { + unload: (): void => {}, + load: () => { + return { + done: (_intercept: Function) => {}, + }; + }, + run: (): void => {}, + }, +}; + +const createElementSpy = jasmine.createSpy('createElement').and.returnValue({}); + +class MockRendererFactory2 { + createRenderer() { + return { + createElement: createElementSpy, + appendChild() {}, + }; + } +} + +const eventListener: Map = >{}; + +const loadQsi = () => { + eventListener[QUALTRICS_EVENT_NAME](new Event(QUALTRICS_EVENT_NAME)); +}; + +@Injectable({ + providedIn: 'root', +}) +class CustomQualtricsLoaderService extends QualtricsLoaderService { + collectData() { + return of(true); + } + protected isDataLoaded() { + return this.collectData(); + } +} + +describe('QualtricsLoaderService', () => { + let service: QualtricsLoaderService; + let winRef: WindowRef; + + beforeEach(() => { + const mockedWindowRef = { + nativeWindow: { + addEventListener: (event, listener) => { + eventListener[event] = listener; + }, + removeEventListener: jasmine.createSpy('removeEventListener'), + QSI: mockQsiJsApi, + }, + document: { + querySelector: () => {}, + }, + }; + + TestBed.configureTestingModule({ + providers: [ + QualtricsLoaderService, + CustomQualtricsLoaderService, + { provide: WindowRef, useValue: mockedWindowRef }, + { provide: RendererFactory2, useClass: MockRendererFactory2 }, + ], + }); + + winRef = TestBed.inject(WindowRef); + service = TestBed.inject(QualtricsLoaderService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('Consume Qualtrics API', () => { + let qsiRun: jasmine.Spy; + let qsiUnload: jasmine.Spy; + + beforeEach(() => { + qsiRun = spyOn(winRef.nativeWindow['QSI'].API, 'run').and.stub(); + qsiUnload = spyOn(winRef.nativeWindow['QSI'].API, 'unload').and.stub(); + }); + + it('should not load Qualtrics when the qsi_js_loaded event is not triggered', () => { + expect(qsiRun).not.toHaveBeenCalled(); + }); + + describe('Qualtrics loaded', () => { + beforeEach(() => { + loadQsi(); + }); + + it('should load Qualtrics API when the qsi_js_loaded event is triggered', () => { + expect(qsiRun).toHaveBeenCalledTimes(1); + }); + + it('should not unload Qualtrics API when the qsi_js_loaded event is triggered', () => { + expect(qsiUnload).not.toHaveBeenCalled(); + }); + + it('should load twice when a the event is dispatched twice', () => { + loadQsi(); + expect(qsiRun).toHaveBeenCalledTimes(2); + }); + + it('should unload when a script is alread in the DOM', () => { + spyOn(winRef.document, 'querySelector').and.returnValue({} as Element); + service.addScript('whatever.js'); + expect(qsiUnload).toHaveBeenCalled(); + }); + }); + }); + + describe('addScript()', () => { + beforeEach(() => { + loadQsi(); + }); + + it('should add the deployment script', () => { + service.addScript('whatever.js'); + expect(createElementSpy).toHaveBeenCalledWith('script'); + }); + + it('should not add the same script twice', () => { + createElementSpy.calls.reset(); + // simulate script has been added + spyOn(winRef.document, 'querySelector').and.returnValue({} as Element); + service.addScript('whatever2.js'); + expect(createElementSpy).not.toHaveBeenCalled(); + }); + }); + + describe('custom service', () => { + it('should invoke custom data collector', () => { + const customService = TestBed.inject(CustomQualtricsLoaderService); + spyOn(customService, 'collectData').and.callThrough(); + + eventListener[QUALTRICS_EVENT_NAME](new Event(QUALTRICS_EVENT_NAME)); + + expect(customService.collectData).toHaveBeenCalled(); + }); + }); +}); diff --git a/feature-libs/qualtrics/components/qualtrics-loader/qualtrics-loader.service.ts b/feature-libs/qualtrics/components/qualtrics-loader/qualtrics-loader.service.ts new file mode 100644 index 00000000000..1716f029f8c --- /dev/null +++ b/feature-libs/qualtrics/components/qualtrics-loader/qualtrics-loader.service.ts @@ -0,0 +1,142 @@ +import { + Injectable, + isDevMode, + OnDestroy, + Renderer2, + RendererFactory2, +} from '@angular/core'; +import { WindowRef } from '@spartacus/core'; +import { EMPTY, fromEvent, Observable, of, Subscription } from 'rxjs'; +import { filter, map, switchMap, tap } from 'rxjs/operators'; + +export const QUALTRICS_EVENT_NAME = 'qsi_js_loaded'; + +/** + * Service to integration Qualtrics. + * + * The integration observes the Qualtrics API, and when available, it runs the QSI API + * to let Qualtrics evaluate the application. + * + * The service supports an additional _hook_ (`isDataLoaded()`) that can be used to load application + * data before pulling the QSI API. This is beneficial in a single page application when additional + * data is required before the Qualtrics _creatives_ run. + * + * This service also supports the creation of the Qualtrics deployment script. This is optional, as + * the script can be added in alternatives ways. + */ +@Injectable({ + providedIn: 'root', +}) +export class QualtricsLoaderService implements OnDestroy { + protected subscription = new Subscription(); + + /** + * Reference to the QSI API. + */ + protected qsiApi: any; + + /** + * QSI load event that happens when the QSI JS file is loaded. + */ + private qsiLoaded$: Observable = this.winRef?.nativeWindow + ? fromEvent(this.winRef.nativeWindow, QUALTRICS_EVENT_NAME) + : of(); + + /** + * Emits the Qualtrics Site Intercept (QSI) JavaScript API whenever available. + * + * The API is emitted when the JavaScript resource holding this API is fully loaded. + * The API is also stored locally in the service, in case it's required later on. + */ + protected qsi$: Observable = this.qsiLoaded$.pipe( + switchMap(() => this.isDataLoaded()), + map((dataLoaded) => + dataLoaded ? this.winRef?.nativeWindow['QSI'] : EMPTY + ), + filter((api) => Boolean(api)), + tap((qsi) => (this.qsiApi = qsi)) + ); + + constructor( + protected winRef: WindowRef, + protected rendererFactory: RendererFactory2 + ) { + this.initialize(); + } + + /** + * Adds the deployment script to the DOM. + * + * The script will not be added twice if it was loaded before. In that case, we use + * the Qualtrics API directly to _unload_ and _run_ the project. + */ + addScript(scriptSource: string): void { + if (this.hasScript(scriptSource)) { + this.run(true); + } else { + const script: HTMLScriptElement = this.renderer.createElement('script'); + script.type = 'text/javascript'; + script.defer = true; + script.src = scriptSource; + this.renderer.appendChild(this.winRef.document.body, script); + } + } + + /** + * Indicates if the script is already added to the DOM. + */ + hasScript(source?: string): boolean { + return !!this.winRef.document.querySelector(`script[src="${source}"]`); + } + + /** + * Starts observing the Qualtrics integration. The integration is based on a + * Qualtrics specific event (`qsi_js_loaded`). As soon as this events happens, + * we run the API. + */ + protected initialize() { + this.subscription.add(this.qsi$.subscribe(() => this.run())); + } + + /** + * Evaluates the Qualtrics project code for the application. + * + * In order to reload the evaluation in Qualtrics, the API requires to unload the API before + * running it again. We don't do this by default, but offer a flag to conditionally unload the API. + */ + protected run(reload = false): void { + if (!this.qsiApi?.API) { + if (isDevMode()) { + console.log('The QSI api is not available'); + } + return; + } + + if (reload) { + // Removes any currently displaying creatives + this.qsiApi.API.unload(); + } + + // Starts the intercept code evaluation right after loading the Site Intercept + // code for any defined intercepts or creatives + this.qsiApi.API.load().done(this.qsiApi.API.run()); + } + + /** + * This logic exist in order to let the client(s) add their own logic to wait for any kind of page data. + * You can observe any data in this method. + * + * Defaults to true. + */ + protected isDataLoaded(): Observable { + return of(true); + } + + protected get renderer(): Renderer2 { + return this.rendererFactory.createRenderer(null, null); + } + + ngOnDestroy() { + this.subscription?.unsubscribe(); + } +} diff --git a/feature-libs/qualtrics/components/qualtrics-loader/qualtrics.component.spec.ts b/feature-libs/qualtrics/components/qualtrics-loader/qualtrics.component.spec.ts new file mode 100644 index 00000000000..bcbdef50b35 --- /dev/null +++ b/feature-libs/qualtrics/components/qualtrics-loader/qualtrics.component.spec.ts @@ -0,0 +1,78 @@ +import { + ComponentFixture, + TestBed, + TestBedStatic, +} from '@angular/core/testing'; +import { QualtricsConfig } from './config/qualtrics-config'; +import { QualtricsLoaderService } from './qualtrics-loader.service'; +import { QualtricsComponent } from './qualtrics.component'; + +const mockQualtricsConfig: QualtricsConfig = { + qualtrics: { + scriptSource: 'assets/deployment-script.js', + }, +}; + +class MockQualtricsLoaderService { + addScript(): void {} +} + +describe('QualtricsComponent', () => { + let component: QualtricsComponent; + let fixture: ComponentFixture; + let service: QualtricsLoaderService; + + function configureTestingModule(): TestBedStatic { + return TestBed.configureTestingModule({ + declarations: [QualtricsComponent], + providers: [ + { + provide: QualtricsLoaderService, + useClass: MockQualtricsLoaderService, + }, + { provide: QualtricsConfig, useValue: mockQualtricsConfig }, + ], + }); + } + + function stubSeviceAndCreateComponent() { + service = TestBed.inject(QualtricsLoaderService); + spyOn(service, 'addScript').and.stub(); + + fixture = TestBed.createComponent(QualtricsComponent); + component = fixture.componentInstance; + } + + describe('with config', () => { + beforeEach(() => { + configureTestingModule(); + stubSeviceAndCreateComponent(); + }); + + it('should create component', () => { + expect(component).toBeTruthy(); + }); + + it('should add qualtrics script', () => { + fixture.detectChanges(); + expect(service.addScript).toHaveBeenCalledWith( + 'assets/deployment-script.js' + ); + }); + }); + + describe('without config', () => { + it('should NOT add qualtrics script', () => { + configureTestingModule().overrideProvider(QualtricsConfig, { + useValue: {}, + }); + TestBed.compileComponents(); + + stubSeviceAndCreateComponent(); + + expect(service.addScript).not.toHaveBeenCalledWith( + 'assets/deployment-script.js' + ); + }); + }); +}); diff --git a/feature-libs/qualtrics/components/qualtrics-loader/qualtrics.component.ts b/feature-libs/qualtrics/components/qualtrics-loader/qualtrics.component.ts new file mode 100644 index 00000000000..33fdd719fac --- /dev/null +++ b/feature-libs/qualtrics/components/qualtrics-loader/qualtrics.component.ts @@ -0,0 +1,25 @@ +import { Component, isDevMode } from '@angular/core'; +import { QualtricsConfig } from './config/qualtrics-config'; +import { QualtricsLoaderService } from './qualtrics-loader.service'; +/** + * Adds the Qualtrics deployment script whenever the component is loaded. The + * deployment script is loaded from the global configuration (`qualtrics.scriptSource`). + */ +@Component({ + selector: 'cx-qualtrics', + template: '', +}) +export class QualtricsComponent { + constructor( + protected qualtricsLoader: QualtricsLoaderService, + protected config: QualtricsConfig + ) { + if (this.config.qualtrics?.scriptSource) { + this.qualtricsLoader.addScript(this.config.qualtrics.scriptSource); + } else if (isDevMode()) { + console.warn( + `We're unable to add the Qualtrics deployment code as there is no script source defined in config.qualtrics.scriptSource.` + ); + } + } +} diff --git a/feature-libs/qualtrics/jest.schematics.config.js b/feature-libs/qualtrics/jest.schematics.config.js new file mode 100644 index 00000000000..8ada4118fdf --- /dev/null +++ b/feature-libs/qualtrics/jest.schematics.config.js @@ -0,0 +1,29 @@ +const { pathsToModuleNameMapper } = require('ts-jest/utils'); +const { compilerOptions } = require('./tsconfig.schematics'); + +module.exports = { + setupFilesAfterEnv: ['/jest.ts'], + transform: { + '^.+\\.ts?$': 'ts-jest', + }, + + collectCoverage: false, + coverageReporters: ['json', 'lcov', 'text', 'clover'], + coverageDirectory: '/../../coverage/qualtrics/schematics', + coverageThreshold: { + global: { + branches: 70, + functions: 80, + lines: 80, + statements: 80, + }, + }, + + roots: ['/schematics'], + modulePaths: ['/../../projects/schematics'], + testMatch: ['**/+(*_)+(spec).+(ts)'], + moduleFileExtensions: ['js', 'ts', 'json'], + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths || {}, { + prefix: '/', + }), +}; diff --git a/feature-libs/qualtrics/jest.ts b/feature-libs/qualtrics/jest.ts new file mode 100644 index 00000000000..fa0c277b54a --- /dev/null +++ b/feature-libs/qualtrics/jest.ts @@ -0,0 +1,24 @@ +// uncomment when we switch the whole lib to jest +/** +Object.defineProperty(window, 'CSS', { value: null }); +Object.defineProperty(window, 'getComputedStyle', { + value: () => { + return { + display: 'none', + appearance: ['-webkit-appearance'], + }; + }, +}); + +Object.defineProperty(document, 'doctype', { + value: '', +}); +Object.defineProperty(document.body.style, 'transform', { + value: () => { + return { + enumerable: true, + configurable: true, + }; + }, +}); +*/ diff --git a/feature-libs/qualtrics/karma.conf.js b/feature-libs/qualtrics/karma.conf.js new file mode 100644 index 00000000000..dd76e9e561d --- /dev/null +++ b/feature-libs/qualtrics/karma.conf.js @@ -0,0 +1,40 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-coverage'), + require('karma-junit-reporter'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage-istanbul-reporter'), + require('@angular-devkit/build-angular/plugins/karma'), + ], + client: { + clearContext: false, // leave Jasmine Spec Runner output visible in browser + }, + reporters: ['progress', 'kjhtml', 'coverage-istanbul', 'dots'], + coverageIstanbulReporter: { + dir: require('path').join(__dirname, '../../coverage/qualtrics'), + reports: ['lcov', 'cobertura', 'text-summary'], + fixWebpackSourcePaths: true, + thresholds: { + statements: 80, + lines: 80, + branches: 60, + functions: 80, + }, + }, + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false, + restartOnFileChange: true, + }); +}; diff --git a/feature-libs/qualtrics/ng-package.json b/feature-libs/qualtrics/ng-package.json new file mode 100644 index 00000000000..c291a0459b2 --- /dev/null +++ b/feature-libs/qualtrics/ng-package.json @@ -0,0 +1,12 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/qualtrics", + "lib": { + "entryFile": "./public_api.ts", + "umdModuleIds": { + "@spartacus/core": "core", + "rxjs": "rxjs" + } + }, + "assets": ["**/*.scss", "schematics/**/*.json", "schematics/**/*.js"] +} diff --git a/feature-libs/qualtrics/package.json b/feature-libs/qualtrics/package.json new file mode 100644 index 00000000000..9d971549e6c --- /dev/null +++ b/feature-libs/qualtrics/package.json @@ -0,0 +1,37 @@ +{ + "name": "@spartacus/qualtrics", + "version": "3.1.0", + "description": "Qualtrics library for Spartacus", + "homepage": "https://github.com/SAP/spartacus", + "keywords": [ + "spartacus", + "framework", + "storefront", + "qualtrics", + "personalized", + "management" + ], + "scripts": { + "clean:schematics": "../../node_modules/.bin/rimraf \"schematics/**/*.js\" \"schematics/**/*.js.map\" \"schematics/**/*.d.ts\"", + "build:schematics": "yarn clean:schematics && ../../node_modules/.bin/tsc -p ./tsconfig.schematics.json", + "test:schematics": "yarn --cwd ../../projects/schematics/ run clean && yarn clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" + }, + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "repository": "https://github.com/SAP/spartacus/tree/develop/feature-libs/qualtrics", + "schematics": "./schematics/collection.json", + "peerDependencies": { + "@angular-devkit/schematics": "^10.1.0", + "@angular/common": "^10.1.0", + "@angular/core": "^10.1.0", + "@spartacus/core": "3.0.0", + "@spartacus/schematics": "3.0.0", + "bootstrap": "^4.0", + "rxjs": "^6.6.0" + }, + "dependencies": { + "tslib": "^2.0.0" + } +} diff --git a/feature-libs/qualtrics/public_api.ts b/feature-libs/qualtrics/public_api.ts new file mode 100644 index 00000000000..b935db65b68 --- /dev/null +++ b/feature-libs/qualtrics/public_api.ts @@ -0,0 +1,5 @@ +/* + * Public API Surface of qualtrics + */ + +export * from './qualtrics.module'; diff --git a/feature-libs/qualtrics/qualtrics.module.ts b/feature-libs/qualtrics/qualtrics.module.ts new file mode 100644 index 00000000000..5d766e643a7 --- /dev/null +++ b/feature-libs/qualtrics/qualtrics.module.ts @@ -0,0 +1,7 @@ +import { NgModule } from '@angular/core'; +import { QualtricsComponentsModule } from '@spartacus/qualtrics/components'; + +@NgModule({ + imports: [QualtricsComponentsModule], +}) +export class QualtricsModule {} diff --git a/feature-libs/qualtrics/root/ng-package.json b/feature-libs/qualtrics/root/ng-package.json new file mode 100644 index 00000000000..6ffef75176d --- /dev/null +++ b/feature-libs/qualtrics/root/ng-package.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts", + "umdModuleIds": { + "@spartacus/core": "core" + } + } +} diff --git a/feature-libs/qualtrics/root/public_api.ts b/feature-libs/qualtrics/root/public_api.ts new file mode 100644 index 00000000000..129fca3a8ed --- /dev/null +++ b/feature-libs/qualtrics/root/public_api.ts @@ -0,0 +1 @@ +export * from './qualtrics-root.module'; diff --git a/feature-libs/qualtrics/root/qualtrics-root.module.ts b/feature-libs/qualtrics/root/qualtrics-root.module.ts new file mode 100644 index 00000000000..2a27c83a634 --- /dev/null +++ b/feature-libs/qualtrics/root/qualtrics-root.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { provideDefaultConfig } from '@spartacus/core'; + +@NgModule({ + providers: [ + provideDefaultConfig({ + featureModules: { + qualtrics: { + cmsComponents: ['QualtricsEmbeddedFeedbackComponent'], + }, + }, + }), + ], +}) +export class QualtricsRootModule {} diff --git a/feature-libs/qualtrics/schematics/.gitignore b/feature-libs/qualtrics/schematics/.gitignore new file mode 100644 index 00000000000..c88f4d69e15 --- /dev/null +++ b/feature-libs/qualtrics/schematics/.gitignore @@ -0,0 +1,18 @@ +# Outputs +**/*.js +**/*.js.map +**/*.d.ts + +# IDEs +.idea/ +jsconfig.json +.vscode/ + +# Misc +node_modules/ +npm-debug.log* +yarn-error.log* + +# Mac OSX Finder files. +**/.DS_Store +.DS_Store diff --git a/feature-libs/qualtrics/schematics/add-qualtrics/index.ts b/feature-libs/qualtrics/schematics/add-qualtrics/index.ts new file mode 100644 index 00000000000..acfaa1195a1 --- /dev/null +++ b/feature-libs/qualtrics/schematics/add-qualtrics/index.ts @@ -0,0 +1,74 @@ +import { + chain, + Rule, + SchematicContext, + Tree, +} from '@angular-devkit/schematics'; +import { + NodeDependency, + NodeDependencyType, +} from '@schematics/angular/utility/dependencies'; +import { + addLibraryFeature, + addPackageJsonDependencies, + getAppModule, + getSpartacusSchematicsVersion, + installPackageJsonDependencies, + LibraryOptions as SpartacusQualtricsOptions, + QUALTRICS_EMBEDDED_FEEDBACK_SCSS_FILE_NAME, + QUALTRICS_FEATURE_NAME, + QUALTRICS_MODULE, + QUALTRICS_ROOT_MODULE, + readPackageJson, + SPARTACUS_QUALTRICS, + SPARTACUS_QUALTRICS_ROOT, + validateSpartacusInstallation, +} from '@spartacus/schematics'; + +export function addQualtricsFeatures(options: SpartacusQualtricsOptions): Rule { + return (tree: Tree, _context: SchematicContext) => { + const packageJson = readPackageJson(tree); + validateSpartacusInstallation(packageJson); + + const appModulePath = getAppModule(tree, options.project); + + return chain([ + addQualtricsFeature(appModulePath, options), + addQualtricsPackageJsonDependencies(packageJson), + installPackageJsonDependencies(), + ]); + }; +} + +function addQualtricsFeature( + appModulePath: string, + options: SpartacusQualtricsOptions +): Rule { + return addLibraryFeature(appModulePath, options, { + name: QUALTRICS_FEATURE_NAME, + featureModule: { + name: QUALTRICS_MODULE, + importPath: SPARTACUS_QUALTRICS, + }, + rootModule: { + name: QUALTRICS_ROOT_MODULE, + importPath: SPARTACUS_QUALTRICS_ROOT, + }, + styles: { + scssFileName: QUALTRICS_EMBEDDED_FEEDBACK_SCSS_FILE_NAME, + importStyle: SPARTACUS_QUALTRICS, + }, + }); +} + +function addQualtricsPackageJsonDependencies(packageJson: any): Rule { + const spartacusVersion = `^${getSpartacusSchematicsVersion()}`; + const dependencies: NodeDependency[] = [ + { + type: NodeDependencyType.Default, + version: spartacusVersion, + name: SPARTACUS_QUALTRICS, + }, + ]; + return addPackageJsonDependencies(dependencies, packageJson); +} diff --git a/feature-libs/qualtrics/schematics/add-qualtrics/index_spec.ts b/feature-libs/qualtrics/schematics/add-qualtrics/index_spec.ts new file mode 100644 index 00000000000..051c2287243 --- /dev/null +++ b/feature-libs/qualtrics/schematics/add-qualtrics/index_spec.ts @@ -0,0 +1,199 @@ +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import { + LibraryOptions as SpartacusQualtricsOptions, + SpartacusOptions, + SPARTACUS_QUALTRICS, +} from '@spartacus/schematics'; +import * as path from 'path'; + +const collectionPath = path.join(__dirname, '../collection.json'); +const appModulePath = 'src/app/app.module.ts'; + +describe('Spartacus Qualtrics schematics: ng-add', () => { + const schematicRunner = new SchematicTestRunner('schematics', collectionPath); + + let appTree: UnitTestTree; + + const workspaceOptions: any = { + name: 'workspace', + version: '0.5.0', + }; + + const appOptions: any = { + name: 'schematics-test', + inlineStyle: false, + inlineTemplate: false, + routing: false, + style: 'scss', + skipTests: false, + projectRoot: '', + }; + + const defaultOptions: SpartacusQualtricsOptions = { + project: 'schematics-test', + lazy: true, + features: [], + }; + + const spartacusDefaultOptions: SpartacusOptions = { + project: 'schematics-test', + }; + + beforeEach(async () => { + schematicRunner.registerCollection( + '@spartacus/schematics', + '../../projects/schematics/src/collection.json' + ); + schematicRunner.registerCollection( + '@spartacus/organization', + '../../feature-libs/organization/schematics/collection.json' + ); + + appTree = await schematicRunner + .runExternalSchematicAsync( + '@schematics/angular', + 'workspace', + workspaceOptions + ) + .toPromise(); + appTree = await schematicRunner + .runExternalSchematicAsync( + '@schematics/angular', + 'application', + appOptions, + appTree + ) + .toPromise(); + appTree = await schematicRunner + .runExternalSchematicAsync( + '@spartacus/schematics', + 'ng-add', + { ...spartacusDefaultOptions, name: 'schematics-test' }, + appTree + ) + .toPromise(); + }); + + describe('Qualtrics feature', () => { + describe('styling', () => { + beforeEach(async () => { + appTree = await schematicRunner + .runSchematicAsync('ng-add', defaultOptions, appTree) + .toPromise(); + }); + + it('should install @spartacus/qualtrics library', () => { + const packageJson = appTree.readContent('package.json'); + expect(packageJson).toContain(SPARTACUS_QUALTRICS); + }); + + it('should add style import to /src/styles/spartacus/qualtrics-embedded-feedback.scss', async () => { + const content = appTree.readContent( + '/src/styles/spartacus/qualtrics-embedded-feedback.scss' + ); + expect(content).toEqual(`@import "@spartacus/qualtrics";`); + }); + + it('should add update angular.json with spartacus/qualtrics-embedded-feedback.scss', async () => { + const content = appTree.readContent('/angular.json'); + const angularJson = JSON.parse(content); + const buildStyles: string[] = + angularJson.projects['schematics-test'].architect.build.options + .styles; + expect(buildStyles).toEqual([ + 'src/styles.scss', + 'src/styles/spartacus/qualtrics-embedded-feedback.scss', + ]); + + const testStyles: string[] = + angularJson.projects['schematics-test'].architect.test.options.styles; + expect(testStyles).toEqual([ + 'src/styles.scss', + 'src/styles/spartacus/qualtrics-embedded-feedback.scss', + ]); + }); + }); + + describe('eager loading', () => { + beforeEach(async () => { + appTree = await schematicRunner + .runSchematicAsync( + 'ng-add', + { ...defaultOptions, lazy: false }, + appTree + ) + .toPromise(); + }); + + it('should add qualtrics deps', async () => { + const packageJson = appTree.readContent('/package.json'); + const packageObj = JSON.parse(packageJson); + const depPackageList = Object.keys(packageObj.dependencies); + expect(depPackageList.includes('@spartacus/qualtrics')).toBe(true); + }); + + it('should import appropriate modules', async () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).toContain( + `import { QualtricsRootModule } from '@spartacus/qualtrics/root';` + ); + expect(appModule).toContain( + `import { QualtricsModule } from '@spartacus/qualtrics';` + ); + }); + + it('should not contain lazy loading syntax', async () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).not.toContain(`import('@spartacus/qualtrics').then(`); + }); + }); + + describe('lazy loading', () => { + beforeEach(async () => { + appTree = await schematicRunner + .runSchematicAsync('ng-add', defaultOptions, appTree) + .toPromise(); + }); + + it('should import QualtricsRootModule and contain the lazy loading syntax', async () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).toContain( + `import { QualtricsRootModule } from '@spartacus/qualtrics/root';` + ); + expect(appModule).toContain(`import('@spartacus/qualtrics').then(`); + }); + + it('should not contain the QualtricsModule import', () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).not.toContain( + `import { QualtricsModule } from '@spartacus/qualtrics';` + ); + }); + }); + }); + + describe('when other Spartacus features are already installed', () => { + beforeEach(async () => { + appTree = await schematicRunner + .runExternalSchematicAsync( + '@spartacus/organization', + 'ng-add', + { ...spartacusDefaultOptions, name: 'schematics-test' }, + appTree + ) + .toPromise(); + appTree = await schematicRunner + .runSchematicAsync('ng-add', defaultOptions, appTree) + .toPromise(); + }); + + it('should just append qualtrics feature without duplicating the featureModules config', () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule.match(/featureModules:/g).length).toEqual(1); + expect(appModule).toContain(`qualtrics: {`); + }); + }); +}); diff --git a/feature-libs/qualtrics/schematics/add-qualtrics/schema.json b/feature-libs/qualtrics/schematics/add-qualtrics/schema.json new file mode 100644 index 00000000000..ca8b7626d48 --- /dev/null +++ b/feature-libs/qualtrics/schematics/add-qualtrics/schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "QualtricsSchematics", + "title": "Qualtrics Schematics", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + } + }, + "lazy": { + "type": "boolean", + "description": "Lazy load the Qualtrics features.", + "default": true + } + }, + "required": [] +} diff --git a/feature-libs/qualtrics/schematics/collection.json b/feature-libs/qualtrics/schematics/collection.json new file mode 100644 index 00000000000..aa291bd1370 --- /dev/null +++ b/feature-libs/qualtrics/schematics/collection.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "factory": "./add-qualtrics/index#addQualtricsFeatures", + "description": "Add and configure Spartacus' qualtrics features", + "schema": "./add-qualtrics/schema.json", + "private": true, + "hidden": true, + "aliases": ["install"] + } + } +} diff --git a/feature-libs/qualtrics/styles/_index.scss b/feature-libs/qualtrics/styles/_index.scss new file mode 100644 index 00000000000..94d85e4299d --- /dev/null +++ b/feature-libs/qualtrics/styles/_index.scss @@ -0,0 +1 @@ +@import './qualtrics-embedded-feedback'; diff --git a/feature-libs/qualtrics/styles/_qualtrics-embedded-feedback.scss b/feature-libs/qualtrics/styles/_qualtrics-embedded-feedback.scss new file mode 100644 index 00000000000..5b406f0cbd5 --- /dev/null +++ b/feature-libs/qualtrics/styles/_qualtrics-embedded-feedback.scss @@ -0,0 +1,4 @@ +// Qualtrics Embedded Feedback styles +cx-qualtrics-embedded-feedback { + // add any styling properties below +} diff --git a/feature-libs/qualtrics/test.ts b/feature-libs/qualtrics/test.ts new file mode 100644 index 00000000000..0baeec84239 --- /dev/null +++ b/feature-libs/qualtrics/test.ts @@ -0,0 +1,35 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js/dist/zone'; +import 'zone.js/dist/zone-testing'; +import { getTestBed } from '@angular/core/testing'; +import '@angular/localize/init'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +declare const require: { + context( + path: string, + deep?: boolean, + filter?: RegExp + ): { + keys(): string[]; + (id: string): T; + }; +}; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); +// And load the modules. +context + .keys() + // filter tests from node_modules + .filter((key) => !key.startsWith('@')) + .forEach(context); diff --git a/feature-libs/qualtrics/tsconfig.lib.json b/feature-libs/qualtrics/tsconfig.lib.json new file mode 100644 index 00000000000..395c11c3831 --- /dev/null +++ b/feature-libs/qualtrics/tsconfig.lib.json @@ -0,0 +1,28 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib", + "target": "es2015", + "module": "es2020", + "moduleResolution": "node", + "declaration": true, + "sourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "importHelpers": true, + "types": [], + "lib": ["es2020", "dom"], + "paths": { + "@spartacus/core": ["dist/core"] + } + }, + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "fullTemplateTypeCheck": true, + "strictInjectionParameters": true, + "enableResourceInlining": true, + "strictTemplates": true + }, + "exclude": ["test.ts", "**/*.spec.ts"] +} diff --git a/feature-libs/qualtrics/tsconfig.lib.prod.json b/feature-libs/qualtrics/tsconfig.lib.prod.json new file mode 100644 index 00000000000..cbae7942248 --- /dev/null +++ b/feature-libs/qualtrics/tsconfig.lib.prod.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.lib.json", + "angularCompilerOptions": { + "enableIvy": false + } +} diff --git a/feature-libs/qualtrics/tsconfig.schematics.json b/feature-libs/qualtrics/tsconfig.schematics.json new file mode 100644 index 00000000000..bc46282c561 --- /dev/null +++ b/feature-libs/qualtrics/tsconfig.schematics.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "lib": ["es2018", "dom"], + "declaration": true, + "module": "commonjs", + "moduleResolution": "node", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "sourceMap": true, + "strictNullChecks": true, + "target": "es6", + "types": ["jest", "node"], + "resolveJsonModule": true, + "esModuleInterop": true, + "paths": { + "@spartacus/schematics": ["../../projects/schematics/src/public_api"] + } + }, + "include": ["schematics/**/*.ts"], + "exclude": ["schematics/*/files/**/*", "schematics/**/*_spec.ts"] +} diff --git a/feature-libs/qualtrics/tsconfig.spec.json b/feature-libs/qualtrics/tsconfig.spec.json new file mode 100644 index 00000000000..5ac63eb6f0a --- /dev/null +++ b/feature-libs/qualtrics/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "module": "es2020", + "types": ["jasmine", "node"] + }, + "files": ["test.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/feature-libs/qualtrics/tslint.json b/feature-libs/qualtrics/tslint.json new file mode 100644 index 00000000000..fe91d36a7a9 --- /dev/null +++ b/feature-libs/qualtrics/tslint.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tslint.json", + "rules": { + "directive-selector": [true, "attribute", "cx", "camelCase"], + "component-selector": [true, "element", "cx", "kebab-case"], + "no-host-metadata-property": false + } +} diff --git a/package.json b/package.json index 2faa3bcc9c9..3cfbbc4f668 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "build": "ng build storefrontapp --prod", "build:assets": "yarn --cwd ./projects/assets build", - "build:libs": "ng build core --prod && ng build storefrontlib --prod && yarn build:schematics && yarn build:organization && yarn build:product && yarn build:storefinder && yarn build:assets && yarn build:incubator && yarn build:cdc && yarn build:cds:lib && yarn build:setup", + "build:libs": "ng build core --prod && ng build storefrontlib --prod && yarn build:schematics && yarn build:organization && yarn build:product && yarn build:storefinder && yarn build:qualtrics && yarn build:assets && yarn build:incubator && yarn build:cdc && yarn build:cds:lib && yarn build:setup", "build:incubator": "ng build incubator --prod", "build:setup": "ng build setup --prod", "e2e:cy:open": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:open", @@ -53,7 +53,7 @@ "start:ssl": "ng serve --ssl", "start:pwa": "cd ./dist/storefrontapp/ && http-server -p 4200", "test": "ng test", - "test:libs": "concurrently \"ng test core --code-coverage\" \"ng test storefrontlib --code-coverage\" \"ng test organization --code-coverage\" \"ng test storefinder --code-coverage\" \"ng test product --code-coverage\" \"ng test cdc --code-coverage\" \"ng test setup --code-coverage\"", + "test:libs": "concurrently \"ng test core --code-coverage\" \"ng test storefrontlib --code-coverage\" \"ng test organization --code-coverage\" \"ng test storefinder --code-coverage\" \"ng test qualtrics --code-coverage\" \"ng test product --code-coverage\" \"ng test cdc --code-coverage\" \"ng test setup --code-coverage\"", "test:storefront:lib": "ng test storefrontlib --sourceMap --code-coverage", "dev:ssr": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 ng run storefrontapp:serve-ssr", "serve:ssr:dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 node dist/storefrontapp-server/main.js", @@ -93,6 +93,8 @@ "release:product:with-changelog": "cd feature-libs/product && release-it && cd ../..", "build:storefinder": "yarn --cwd feature-libs/storefinder run build:schematics && ng build storefinder --prod", "release:storefinder:with-changelog": "cd feature-libs/storefinder && release-it && cd ../..", + "build:qualtrics": "yarn --cwd feature-libs/qualtrics run build:schematics && ng build qualtrics --prod", + "release:qualtrics:with-changelog": "cd feature-libs/qualtrics && release-it && cd ../..", "start:b2b:ci:2005": "cross-env SPARTACUS_BASE_URL=https://spartacus-dev3.eastus.cloudapp.azure.com:9002 SPARTACUS_API_PREFIX=/occ/v2/ SPARTACUS_B2B=true ng serve --prod", "build:cdc": "ng build cdc --prod", "release:cdc:with-changelog": "cd integration-libs/cdc && release-it && cd ../..", diff --git a/projects/schematics/package.json b/projects/schematics/package.json index 0ebc3e3a314..c9f0cf2a66b 100644 --- a/projects/schematics/package.json +++ b/projects/schematics/package.json @@ -45,6 +45,7 @@ "@spartacus/organization", "@spartacus/product", "@spartacus/storefinder", + "@spartacus/qualtrics", "@spartacus/cdc", "@spartacus/setup" ] diff --git a/projects/schematics/src/shared/constants.ts b/projects/schematics/src/shared/constants.ts index 640cd2dea90..766a5aadf1b 100644 --- a/projects/schematics/src/shared/constants.ts +++ b/projects/schematics/src/shared/constants.ts @@ -496,6 +496,9 @@ export const LAYOUT_CONFIG = 'LayoutConfig'; export const TOKEN_REVOCATION_HEADER = 'TOKEN_REVOCATION_HEADER'; export const SPLIT_VIEW_DEACTIVATE_GUARD = 'SplitViewDeactivateGuard'; + +export const QUALTRICS_EMBEDDED_FEEDBACK_SCSS_FILE_NAME = + 'qualtrics-embedded-feedback.scss'; /***** Removed public api end *****/ /***** Properties start *****/ @@ -636,6 +639,12 @@ export const STOREFINDER_TRANSLATIONS = 'storeFinderTranslations'; export const STOREFINDER_TRANSLATION_CHUNKS_CONFIG = 'storeFinderTranslationChunksConfig'; +export const QUALTRICS_MODULE = 'QualtricsModule'; +export const QUALTRICS_FEATURE_NAME = 'qualtrics'; +export const QUALTRICS_ROOT_MODULE = 'QualtricsRootModule'; +export const SPARTACUS_QUALTRICS = '@spartacus/qualtrics'; +export const SPARTACUS_QUALTRICS_ROOT = `${SPARTACUS_QUALTRICS}/root`; + export const FIND_COMPONENTS_BY_IDS_LEGACY = 'findComponentsByIdsLegacy'; /***** APIs end *****/ diff --git a/projects/storefrontapp/src/environments/b2c/b2c.feature.ts b/projects/storefrontapp/src/environments/b2c/b2c.feature.ts index 4d19e6b203c..9201ee97389 100644 --- a/projects/storefrontapp/src/environments/b2c/b2c.feature.ts +++ b/projects/storefrontapp/src/environments/b2c/b2c.feature.ts @@ -1,10 +1,11 @@ -import { B2cStorefrontModule } from '@spartacus/storefront'; -import { FeatureEnvironment } from '../models/feature.model'; -import { StoreFinderRootModule } from '@spartacus/storefinder/root'; +import { QualtricsRootModule } from '@spartacus/qualtrics/root'; import { storeFinderTranslationChunksConfig, storeFinderTranslations, } from '@spartacus/storefinder/assets'; +import { StoreFinderRootModule } from '@spartacus/storefinder/root'; +import { B2cStorefrontModule } from '@spartacus/storefront'; +import { FeatureEnvironment } from '../models/feature.model'; export const b2cFeature: FeatureEnvironment = { imports: [ @@ -30,6 +31,10 @@ export const b2cFeature: FeatureEnvironment = { module: () => import('@spartacus/storefinder').then((m) => m.StoreFinderModule), }, + qualtrics: { + module: () => + import('@spartacus/qualtrics').then((m) => m.QualtricsModule), + }, }, i18n: { resources: storeFinderTranslations, @@ -37,5 +42,6 @@ export const b2cFeature: FeatureEnvironment = { }, }), StoreFinderRootModule, + QualtricsRootModule, ], }; diff --git a/projects/storefrontapp/src/styles/lib-qualtrics.scss b/projects/storefrontapp/src/styles/lib-qualtrics.scss new file mode 100644 index 00000000000..94086771727 --- /dev/null +++ b/projects/storefrontapp/src/styles/lib-qualtrics.scss @@ -0,0 +1 @@ +@import 'qualtrics'; diff --git a/projects/storefrontapp/tsconfig.app.prod.json b/projects/storefrontapp/tsconfig.app.prod.json index eb2f03539a4..1189c2a56ea 100644 --- a/projects/storefrontapp/tsconfig.app.prod.json +++ b/projects/storefrontapp/tsconfig.app.prod.json @@ -52,6 +52,9 @@ "@spartacus/storefinder": ["dist/storefinder"], "@spartacus/storefinder/occ": ["dist/storefinder/occ"], "@spartacus/storefinder/root": ["dist/storefinder/root"], + "@spartacus/qualtrics/components": ["dist/qualtrics/components"], + "@spartacus/qualtrics": ["dist/qualtrics"], + "@spartacus/qualtrics/root": ["dist/qualtrics/root"], "@spartacus/cdc": ["dist/cdc"], "@spartacus/cds": ["dist/cds"], "@spartacus/assets": ["dist/assets"], diff --git a/projects/storefrontapp/tsconfig.server.json b/projects/storefrontapp/tsconfig.server.json index 44152d627cc..88658b0767d 100644 --- a/projects/storefrontapp/tsconfig.server.json +++ b/projects/storefrontapp/tsconfig.server.json @@ -68,6 +68,13 @@ "@spartacus/storefinder/root": [ "../../feature-libs/storefinder/root/public_api" ], + "@spartacus/qualtrics/components": [ + "../../feature-libs/qualtrics/components/public_api" + ], + "@spartacus/qualtrics": ["../../feature-libs/qualtrics/public_api"], + "@spartacus/qualtrics/root": [ + "../../feature-libs/qualtrics/root/public_api" + ], "@spartacus/cdc": ["../../integration-libs/cdc/public_api"], "@spartacus/cds": ["../../integration-libs/cds/public_api"], "@spartacus/assets": ["../../projects/assets/src/public_api"], diff --git a/projects/storefrontapp/tsconfig.server.prod.json b/projects/storefrontapp/tsconfig.server.prod.json index ef001fb62c9..316ce901fd3 100644 --- a/projects/storefrontapp/tsconfig.server.prod.json +++ b/projects/storefrontapp/tsconfig.server.prod.json @@ -55,6 +55,9 @@ "@spartacus/storefinder": ["../../dist/storefinder"], "@spartacus/storefinder/occ": ["../../dist/storefinder/occ"], "@spartacus/storefinder/root": ["../../dist/storefinder/root"], + "@spartacus/qualtrics/components": ["../../dist/qualtrics/components"], + "@spartacus/qualtrics": ["../../dist/qualtrics"], + "@spartacus/qualtrics/root": ["../../dist/qualtrics/root"], "@spartacus/cdc": ["../../dist/cdc"], "@spartacus/cds": ["../../dist/cds"], "@spartacus/assets": ["../../dist/assets"], diff --git a/projects/storefrontlib/src/cms-components/misc/qualtrics/config/default-qualtrics-config.ts b/projects/storefrontlib/src/cms-components/misc/qualtrics/config/default-qualtrics-config.ts index 9951aaa37ee..7048a13ce68 100644 --- a/projects/storefrontlib/src/cms-components/misc/qualtrics/config/default-qualtrics-config.ts +++ b/projects/storefrontlib/src/cms-components/misc/qualtrics/config/default-qualtrics-config.ts @@ -1,5 +1,10 @@ import { QualtricsConfig } from './qualtrics-config'; - +/** + * @deprecated since 3.1 - moved to feature-lib + * Please take a look at https://sap.github.io/spartacus-docs/qualtrics-integration/#page-title + * to see how to migrate into the new feature-lib. + * Do not import from the storefront. Instead import from the qualtrics feature-lib. + */ export const defaultQualtricsConfig: QualtricsConfig = { qualtrics: {}, }; diff --git a/projects/storefrontlib/src/cms-components/misc/qualtrics/qualtrics-loader.service.ts b/projects/storefrontlib/src/cms-components/misc/qualtrics/qualtrics-loader.service.ts index 2dc9162b1b9..302157abb87 100644 --- a/projects/storefrontlib/src/cms-components/misc/qualtrics/qualtrics-loader.service.ts +++ b/projects/storefrontlib/src/cms-components/misc/qualtrics/qualtrics-loader.service.ts @@ -11,6 +11,11 @@ import { filter, map, switchMap, tap } from 'rxjs/operators'; export const QUALTRICS_EVENT_NAME = 'qsi_js_loaded'; /** + * @deprecated since 3.1 - moved to feature-lib + * Please take a look at https://sap.github.io/spartacus-docs/qualtrics-integration/#page-title + * to see how to migrate into the new feature-lib. + * Do not import from the storefront. Instead import from the qualtrics feature-lib. + * * Service to integration Qualtrics. * * The integration observes the Qualtrics API, and when available, it runs the QSI API @@ -59,6 +64,31 @@ export class QualtricsLoaderService { this.initialize(); } + /** + * Adds the deployment script to the DOM. + * + * The script will not be added twice if it was loaded before. In that case, we use + * the Qualtrics API directly to _unload_ and _run_ the project. + */ + addScript(scriptSource: string): void { + if (this.hasScript(scriptSource)) { + this.run(true); + } else { + const script: HTMLScriptElement = this.renderer.createElement('script'); + script.type = 'text/javascript'; + script.defer = true; + script.src = scriptSource; + this.renderer.appendChild(this.winRef.document.body, script); + } + } + + /** + * Indicates if the script is already added to the DOM. + */ + protected hasScript(source?: string): boolean { + return !!this.winRef.document.querySelector(`script[src="${source}"]`); + } + /** * Starts observing the Qualtrics integration. The integration is based on a * Qualtrics specific event (`qsi_js_loaded`). As soon as this events happens, @@ -92,24 +122,6 @@ export class QualtricsLoaderService { this.qsiApi.API.load().done(this.qsiApi.API.run()); } - /** - * Adds the deployment script to the DOM. - * - * The script will not be added twice if it was loaded before. In that case, we use - * the Qualtrics API directly to _unload_ and _run_ the project. - */ - addScript(scriptSource: string): void { - if (this.hasScript(scriptSource)) { - this.run(true); - } else { - const script: HTMLScriptElement = this.renderer.createElement('script'); - script.type = 'text/javascript'; - script.defer = true; - script.src = scriptSource; - this.renderer.appendChild(this.winRef.document.body, script); - } - } - /** * This logic exist in order to let the client(s) add their own logic to wait for any kind of page data. * You can observe any data in this method. @@ -120,13 +132,6 @@ export class QualtricsLoaderService { return of(true); } - /** - * Indicates if the script is already added to the DOM. - */ - protected hasScript(source?: string): boolean { - return !!this.winRef.document.querySelector(`script[src="${source}"]`); - } - protected get renderer(): Renderer2 { return this.rendererFactory.createRenderer(null, null); } diff --git a/projects/storefrontlib/src/cms-components/misc/qualtrics/qualtrics.component.ts b/projects/storefrontlib/src/cms-components/misc/qualtrics/qualtrics.component.ts index 00fbe7c80c6..434e104bbb0 100644 --- a/projects/storefrontlib/src/cms-components/misc/qualtrics/qualtrics.component.ts +++ b/projects/storefrontlib/src/cms-components/misc/qualtrics/qualtrics.component.ts @@ -2,6 +2,11 @@ import { Component, isDevMode } from '@angular/core'; import { QualtricsConfig } from './config/qualtrics-config'; import { QualtricsLoaderService } from './qualtrics-loader.service'; /** + * @deprecated since 3.1 - moved to feature-lib + * Please take a look at https://sap.github.io/spartacus-docs/qualtrics-integration/#page-title + * to see how to migrate into the new feature-lib. + * Do not import from the storefront. Instead import from the qualtrics feature-lib. + * * Adds the Qualtrics deployment script whenever the component is loaded. The * deployment script is loaded from the global configuration (`qualtrics.scriptSource`). */ diff --git a/projects/storefrontlib/src/cms-components/misc/qualtrics/qualtrics.module.ts b/projects/storefrontlib/src/cms-components/misc/qualtrics/qualtrics.module.ts index abe150a7c9d..4469e07567c 100644 --- a/projects/storefrontlib/src/cms-components/misc/qualtrics/qualtrics.module.ts +++ b/projects/storefrontlib/src/cms-components/misc/qualtrics/qualtrics.module.ts @@ -4,6 +4,12 @@ import { CmsConfig, provideDefaultConfig } from '@spartacus/core'; import { defaultQualtricsConfig } from './config/default-qualtrics-config'; import { QualtricsComponent } from './qualtrics.component'; +/** + * @deprecated since 3.1 - moved to feature-lib + * Please take a look at https://sap.github.io/spartacus-docs/qualtrics-integration/#page-title + * to see how to migrate into the new feature-lib. + * Do not import from the storefront. Instead import from the qualtrics feature-lib. + */ @NgModule({ imports: [CommonModule], declarations: [QualtricsComponent], diff --git a/scripts/changelog.ts b/scripts/changelog.ts index 4077c02d827..d8927a07687 100644 --- a/scripts/changelog.ts +++ b/scripts/changelog.ts @@ -79,6 +79,7 @@ export default async function run( '@spartacus/organization': 'feature-libs/organization', '@spartacus/product': 'feature-libs/product', '@spartacus/storefinder': 'feature-libs/storefinder', + '@spartacus/qualtrics': 'feature-libs/qualtrics', '@spartacus/cdc': 'integration-libs/cdc', '@spartacus/setup': 'core-libs/setup', }; @@ -310,6 +311,10 @@ if (typeof config.to === 'undefined') { case '@spartacus/storefinder': config.library = '@spartacus/storefinder'; break; + case 'qualtrics': + case '@spartacus/qualtrics': + config.library = '@spartacus/qualtrics'; + break; case 'setup': case '@spartacus/setup': config.library = '@spartacus/setup'; diff --git a/scripts/install/config.default.sh b/scripts/install/config.default.sh index a212841ca79..cd3ca4a1632 100644 --- a/scripts/install/config.default.sh +++ b/scripts/install/config.default.sh @@ -20,6 +20,7 @@ SPARTACUS_PROJECTS=( "core-libs/setup" "feature-libs/organization" "feature-libs/storefinder" + "feature-libs/qualtrics" ) SPARTACUS_REPO_URL="git://github.com/SAP/spartacus.git" diff --git a/scripts/packages.ts b/scripts/packages.ts index 25b445c5f90..656886a0833 100644 --- a/scripts/packages.ts +++ b/scripts/packages.ts @@ -59,6 +59,7 @@ const packageJsonPaths = [ path.join(__dirname, '..', 'feature-libs', 'organization', 'package.json'), path.join(__dirname, '..', 'feature-libs', 'product', 'package.json'), path.join(__dirname, '..', 'feature-libs', 'storefinder', 'package.json'), + path.join(__dirname, '..', 'feature-libs', 'qualtrics', 'package.json'), path.join(__dirname, '..', 'integration-libs', 'cdc', 'package.json'), ]; diff --git a/scripts/templates/changelog.ejs b/scripts/templates/changelog.ejs index dd58ead2128..4a821fce8a5 100644 --- a/scripts/templates/changelog.ejs +++ b/scripts/templates/changelog.ejs @@ -43,6 +43,7 @@ '@spartacus/organization', '@spartacus/product', '@spartacus/storefinder', + '@spartacus/qualtrics', '@spartacus/cdc', '@spartacus/setup' ]; diff --git a/sonar-project.properties b/sonar-project.properties index 37cfb65ce08..1a92d2604fc 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -7,8 +7,8 @@ sonar.exclusions=**/node_modules/** sonar.tests=integration-libs/cds,projects/core,projects/storefrontlib,feature-libs/organization,feature-libs/product,feature-libs/storefinder,integration-libs/cdc,core-libs/setup sonar.test.inclusions=**/*.spec.ts -sonar.typescript.lcov.reportPaths=coverage/cds/lcov.info,coverage/core/lcov.info,coverage/storefront/lcov.info,coverage/organization/lcov.info,coverage/product/lcov.info,coverage/storefinder/lcov.info,coverage/cdc/lcov.info,coverage/setup/lcov.info -sonar.javascript.lcov.reportPaths=coverage/cds/lcov.info,coverage/core/lcov.info,coverage/storefront/lcov.info,coverage/organization/lcov.info,coverage/product/lcov.info,coverage/storefinder/lcov.info,coverage/cdc/lcov.info,coverage/setup/lcov.info +sonar.typescript.lcov.reportPaths=coverage/cds/lcov.info,coverage/core/lcov.info,coverage/storefront/lcov.info,coverage/organization/lcov.info,coverage/product/lcov.info,coverage/storefinder/lcov.info,coverage/qualtrics/lcov.info,coverage/cdc/lcov.info,coverage/setup/lcov.info +sonar.javascript.lcov.reportPaths=coverage/cds/lcov.info,coverage/core/lcov.info,coverage/storefront/lcov.info,coverage/organization/lcov.info,coverage/product/lcov.info,coverage/storefinder/lcov.info,coverage/qualtrics/lcov.info,coverage/cdc/lcov.info,coverage/setup/lcov.info sonar.typescript.tsconfigPath=tslint.json diff --git a/tsconfig.compodoc.json b/tsconfig.compodoc.json index b816181bee2..8ba525a0ede 100644 --- a/tsconfig.compodoc.json +++ b/tsconfig.compodoc.json @@ -95,6 +95,15 @@ "@spartacus/storefinder/root": [ "feature-libs/storefinder/root/public_api" ], + "@spartacus/qualtrics": [ + "feature-libs/qualtrics/public_api" + ], + "@spartacus/qualtrics/root": [ + "feature-libs/qualtrics/root/public_api" + ], + "@spartacus/qualtrics/components": [ + "feature-libs/qualtrics/components/public_api" + ], "@spartacus/cdc": [ "integration-libs/cdc/public_api" ], diff --git a/tsconfig.json b/tsconfig.json index 136a3541ab3..a37442f47d5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -98,6 +98,15 @@ "@spartacus/storefinder/root": [ "feature-libs/storefinder/root/public_api" ], + "@spartacus/qualtrics": [ + "feature-libs/qualtrics/public_api" + ], + "@spartacus/qualtrics/root": [ + "feature-libs/qualtrics/root/public_api" + ], + "@spartacus/qualtrics/components": [ + "feature-libs/qualtrics/components/public_api" + ], "@spartacus/cdc": [ "integration-libs/cdc/public_api" ], From 2edc932ad63a32b1922ca15081b56369a824c462 Mon Sep 17 00:00:00 2001 From: Michal Szczepaniak Date: Sat, 23 Jan 2021 18:06:29 +0100 Subject: [PATCH 16/30] chore: replace map with forEach in storefinder test (#10277) Closes #10277 --- feature-libs/storefinder/test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature-libs/storefinder/test.ts b/feature-libs/storefinder/test.ts index 98a3fb15f06..0baeec84239 100644 --- a/feature-libs/storefinder/test.ts +++ b/feature-libs/storefinder/test.ts @@ -32,4 +32,4 @@ context .keys() // filter tests from node_modules .filter((key) => !key.startsWith('@')) - .map(context); + .forEach(context); From 889235f2c951a6e84359a77f282370b3940c00e6 Mon Sep 17 00:00:00 2001 From: Mateusz Kolasa Date: Mon, 25 Jan 2021 11:10:39 +0100 Subject: [PATCH 17/30] fix: Prevent duplicating calls to CMS on My Company pages --- .../storefrontlib/src/cms-structure/guards/cms-page.guard.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/projects/storefrontlib/src/cms-structure/guards/cms-page.guard.ts b/projects/storefrontlib/src/cms-structure/guards/cms-page.guard.ts index 507a51c5041..908f717175a 100644 --- a/projects/storefrontlib/src/cms-structure/guards/cms-page.guard.ts +++ b/projects/storefrontlib/src/cms-structure/guards/cms-page.guard.ts @@ -9,7 +9,7 @@ import { RoutingService, } from '@spartacus/core'; import { Observable, of } from 'rxjs'; -import { first, switchMap } from 'rxjs/operators'; +import { first, switchMap, take } from 'rxjs/operators'; import { CmsPageGuardService } from './cms-page-guard.service'; @Injectable({ @@ -45,6 +45,7 @@ export class CmsPageGuard implements CanActivate { switchMap((canActivate) => canActivate === true ? this.routingService.getNextPageContext().pipe( + take(1), switchMap((pageContext) => this.cmsService.getPage(pageContext, this.shouldReload()).pipe( first(), From d3284a4835a6247f61e75623e389ae36adddbc39 Mon Sep 17 00:00:00 2001 From: tobi-or-not-tobi Date: Mon, 25 Jan 2021 12:46:53 +0100 Subject: [PATCH 18/30] feat: Optimise page data resolvers in CSR (#10684) closes #10683 Co-authored-by: Krzysztof Platis --- projects/core/src/cms/cms.module.ts | 4 +- .../src/cms/facade/page-meta.service.spec.ts | 211 ++++++++++++------ .../core/src/cms/facade/page-meta.service.ts | 80 +++++-- .../page/config/default-page-meta.config.ts | 33 +++ projects/core/src/cms/page/config/index.ts | 2 + .../src/cms/page/config/page-meta.config.ts | 40 ++++ projects/core/src/cms/page/index.ts | 1 + projects/core/src/cms/page/page.module.ts | 17 +- .../src/cms-structure/seo/seo-meta.service.ts | 2 + .../storefrontlib/src/storefront-config.ts | 4 +- 10 files changed, 310 insertions(+), 84 deletions(-) create mode 100644 projects/core/src/cms/page/config/default-page-meta.config.ts create mode 100644 projects/core/src/cms/page/config/index.ts create mode 100644 projects/core/src/cms/page/config/page-meta.config.ts diff --git a/projects/core/src/cms/cms.module.ts b/projects/core/src/cms/cms.module.ts index b98e8abab40..469c0a39b11 100755 --- a/projects/core/src/cms/cms.module.ts +++ b/projects/core/src/cms/cms.module.ts @@ -1,12 +1,12 @@ import { ModuleWithProviders, NgModule } from '@angular/core'; +import { provideDefaultConfig } from '../config/config-providers'; import { defaultCmsModuleConfig } from './config/default-cms-config'; import { CmsService } from './facade/cms.service'; import { CmsPageTitleModule } from './page/page.module'; import { CmsStoreModule } from './store/cms-store.module'; -import { provideDefaultConfig } from '../config/config-providers'; @NgModule({ - imports: [CmsStoreModule, CmsPageTitleModule], + imports: [CmsStoreModule, CmsPageTitleModule.forRoot()], }) export class CmsModule { static forRoot(): ModuleWithProviders { diff --git a/projects/core/src/cms/facade/page-meta.service.spec.ts b/projects/core/src/cms/facade/page-meta.service.spec.ts index 9cb62c5e970..a9b0d9ebe08 100644 --- a/projects/core/src/cms/facade/page-meta.service.spec.ts +++ b/projects/core/src/cms/facade/page-meta.service.spec.ts @@ -1,4 +1,5 @@ -import { Injectable } from '@angular/core'; +import * as AngularCore from '@angular/core'; +import { Injectable, PLATFORM_ID } from '@angular/core'; import { inject, TestBed } from '@angular/core/testing'; import { Observable, of } from 'rxjs'; import { PageType } from '../../model/cms.model'; @@ -13,6 +14,7 @@ import { PageDescriptionResolver, PageHeadingResolver, PageImageResolver, + PageMetaConfig, PageMetaResolver, PageRobotsResolver, PageTitleResolver, @@ -113,79 +115,158 @@ describe('PageMetaService', () => { let service: PageMetaService; let cmsService: CmsService; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [], - providers: [ - PageMetaService, - ContentPageResolver, - { provide: CmsService, useClass: MockCmsService }, - { - provide: PageMetaResolver, - useExisting: ContentPageResolver, - multi: true, - }, - { - provide: PageMetaResolver, - useExisting: PageWithHeadingResolver, - multi: true, - }, - { - provide: PageMetaResolver, - useExisting: PageWithAllResolvers, - multi: true, - }, - ], + describe('browser', () => { + let resolver: PageWithAllResolvers; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + PageMetaService, + ContentPageResolver, + { provide: CmsService, useClass: MockCmsService }, + { + provide: PageMetaResolver, + useExisting: PageWithAllResolvers, + multi: true, + }, + { provide: PLATFORM_ID, useValue: 'browser' }, + { + provide: PageMetaConfig, + useValue: { + pageMeta: { + resolvers: [ + { + property: 'title', + method: 'resolveTitle', + }, + { + property: 'description', + method: 'resolveDescription', + disabledInCsr: true, + }, + { + property: 'image', + method: 'resolveImage', + disabledInCsr: true, + }, + { + property: 'robots', + method: 'resolveRobots', + disabledInCsr: true, + }, + ], + enableInDevMode: true, + }, + } as PageMetaConfig, + }, + ], + }); + + service = TestBed.inject(PageMetaService); + cmsService = TestBed.inject(CmsService); + + spyOn(cmsService, 'getCurrentPage').and.returnValue(of(mockProductPage)); + resolver = TestBed.inject(PageWithAllResolvers); + spyOn(resolver, 'resolveTitle').and.callThrough(); + spyOn(resolver, 'resolveDescription').and.callThrough(); + spyOn(resolver, 'resolveRobots').and.callThrough(); + spyOn(resolver, 'resolveImage').and.callThrough(); }); - service = TestBed.inject(PageMetaService); - cmsService = TestBed.inject(CmsService); - }); + it('should not resolve disabled resolvers', () => { + spyOnProperty(AngularCore, 'isDevMode').and.returnValue(() => false); + service.getMeta().subscribe().unsubscribe(); + expect(resolver.resolveTitle).toHaveBeenCalled(); + expect(resolver.resolveDescription).not.toHaveBeenCalled(); + expect(resolver.resolveRobots).not.toHaveBeenCalled(); + expect(resolver.resolveImage).not.toHaveBeenCalled(); + }); - it('PageMetaService should be created', () => { - expect(service).toBeTruthy(); + it('should resolve disabled resolvers in devMode', () => { + spyOnProperty(AngularCore, 'isDevMode').and.returnValue(() => true); + service.getMeta().subscribe().unsubscribe(); + expect(resolver.resolveTitle).toHaveBeenCalled(); + expect(resolver.resolveDescription).toHaveBeenCalled(); + expect(resolver.resolveRobots).toHaveBeenCalled(); + expect(resolver.resolveImage).toHaveBeenCalled(); + }); }); - it('should resolve page title using resolveTitle()', () => { - const resolver: ContentPageResolver = TestBed.inject(ContentPageResolver); - spyOn(resolver, 'resolveTitle').and.callThrough(); - service.getMeta().subscribe().unsubscribe(); - expect(resolver.resolveTitle).toHaveBeenCalled(); - }); + describe('server', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + PageMetaService, + ContentPageResolver, + { provide: CmsService, useClass: MockCmsService }, + { + provide: PageMetaResolver, + useExisting: ContentPageResolver, + multi: true, + }, + { + provide: PageMetaResolver, + useExisting: PageWithHeadingResolver, + multi: true, + }, + { + provide: PageMetaResolver, + useExisting: PageWithAllResolvers, + multi: true, + }, + ], + }); - it('should resolve page heading', () => { - spyOn(cmsService, 'getCurrentPage').and.returnValue( - of(mockContentPageWithTemplate) - ); - let result: PageMeta; - service - .getMeta() - .subscribe((value) => { - result = value; - }) - .unsubscribe(); + service = TestBed.inject(PageMetaService); + cmsService = TestBed.inject(CmsService); + }); - expect(result.heading).toEqual('page heading'); - }); + it('PageMetaService should be created', () => { + expect(service).toBeTruthy(); + }); - it('should resolve meta data for product page', () => { - spyOn(cmsService, 'getCurrentPage').and.returnValue(of(mockProductPage)); - let result: PageMeta; - service - .getMeta() - .subscribe((value) => { - result = value; - }) - .unsubscribe(); + it('should resolve page title using resolveTitle()', () => { + const resolver: ContentPageResolver = TestBed.inject(ContentPageResolver); + spyOn(resolver, 'resolveTitle').and.callThrough(); + service.getMeta().subscribe().unsubscribe(); + expect(resolver.resolveTitle).toHaveBeenCalled(); + }); - expect(result.title).toEqual('page title'); - expect(result.heading).toEqual('page heading'); - expect(result.description).toEqual('page description'); - expect(result.breadcrumbs[0].label).toEqual('breadcrumb label'); - expect(result.breadcrumbs[0].link).toEqual('/bread/crumb'); - expect(result.image).toEqual('/my/image.jpg'); - expect(result.robots).toContain(PageRobotsMeta.INDEX); - expect(result.robots).toContain(PageRobotsMeta.FOLLOW); + it('should resolve page heading', () => { + spyOn(cmsService, 'getCurrentPage').and.returnValue( + of(mockContentPageWithTemplate) + ); + let result: PageMeta; + service + .getMeta() + .subscribe((value) => { + result = value; + }) + .unsubscribe(); + + expect(result.heading).toEqual('page heading'); + }); + + it('should resolve meta data for product page', () => { + spyOn(cmsService, 'getCurrentPage').and.returnValue(of(mockProductPage)); + let result: PageMeta; + service + .getMeta() + .subscribe((value) => { + result = value; + }) + .unsubscribe(); + + expect(result.title).toEqual('page title'); + expect(result.heading).toEqual('page heading'); + expect(result.description).toEqual('page description'); + expect(result.breadcrumbs[0].label).toEqual('breadcrumb label'); + expect(result.breadcrumbs[0].link).toEqual('/bread/crumb'); + expect(result.image).toEqual('/my/image.jpg'); + expect(result.robots).toContain(PageRobotsMeta.INDEX); + expect(result.robots).toContain(PageRobotsMeta.FOLLOW); + }); }); }); diff --git a/projects/core/src/cms/facade/page-meta.service.ts b/projects/core/src/cms/facade/page-meta.service.ts index 94b6fc1e78a..ffa683814cb 100644 --- a/projects/core/src/cms/facade/page-meta.service.ts +++ b/projects/core/src/cms/facade/page-meta.service.ts @@ -1,13 +1,21 @@ -import { Injectable } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { Inject, Injectable, isDevMode, PLATFORM_ID } from '@angular/core'; import { defer, Observable, of } from 'rxjs'; import { filter, map, shareReplay, switchMap } from 'rxjs/operators'; import { UnifiedInjector } from '../../lazy-loading/unified-injector'; import { resolveApplicable } from '../../util/applicable'; import { uniteLatest } from '../../util/rxjs/unite-latest'; import { Page, PageMeta } from '../model/page.model'; +import { PageMetaConfig } from '../page/config/page-meta.config'; import { PageMetaResolver } from '../page/page-meta.resolver'; import { CmsService } from './cms.service'; +/** + * Service that collects the page meta data by using injected page resolvers. + * + * Deprecation note: with version 4.0, we'll make the optional constructor arguments mandatory. + */ +// TODO(#10467): Remove and deprecated note. @Injectable({ providedIn: 'root', }) @@ -20,16 +28,20 @@ export class PageMetaService { PageMetaResolver[] >; + // TODO(#10467): Drop optional constructor arguments. constructor( protected cms: CmsService, - protected unifiedInjector?: UnifiedInjector + protected unifiedInjector?: UnifiedInjector, + protected pageMetaConfig?: PageMetaConfig, + @Inject(PLATFORM_ID) protected platformId?: string ) {} + /** * The list of resolver interfaces will be evaluated for the pageResolvers. * - * TODO: optimize browser vs SSR resolvers; image, robots and description - * aren't needed during browsing. + * @deprecated since 3.1, use the configured resolvers instead from `PageMetaConfig.resolvers`. */ + // TODO(#10467): Remove and migrate property protected resolverMethods: { [key: string]: string } = { title: 'resolveTitle', heading: 'resolveHeading', @@ -50,6 +62,11 @@ export class PageMetaService { shareReplay({ bufferSize: 1, refCount: true }) ); + /** + * Returns the observed page meta data for the current page. + * + * The data is resolved by various PageResolvers, which are configurable. + */ getMeta(): Observable { return this.meta$; } @@ -60,23 +77,60 @@ export class PageMetaService { * @param metaResolver */ protected resolve(metaResolver: PageMetaResolver): Observable { - const resolveMethods: Observable[] = Object.keys( - this.resolverMethods - ) - .filter((key) => metaResolver[this.resolverMethods[key]]) - .map((key) => - metaResolver[this.resolverMethods[key]]().pipe( + const resolverMethods = this.getResolverMethods(); + const resolvedData: Observable[] = Object.keys(resolverMethods) + .filter((key) => metaResolver[resolverMethods[key]]) + .map((key) => { + return metaResolver[resolverMethods[key]]().pipe( map((data) => ({ [key]: data, })) - ) - ); + ); + }); - return uniteLatest(resolveMethods).pipe( + return uniteLatest(resolvedData).pipe( map((data) => Object.assign({}, ...data)) ); } + /** + * Returns an object with resolvers. The object properties represent the `PageMeta` property, i.e.: + * + * ``` + * { + * title: 'resolveTitle', + * robots: 'resolveRobots' + * } + * ``` + * + * This list of resolvers is filtered for CSR vs SSR processing since not all resolvers are + * relevant during browsing. + */ + protected getResolverMethods(): { [property: string]: string } { + let resolverMethods = {}; + const configured = this.pageMetaConfig?.pageMeta?.resolvers; + if (configured) { + configured + // filter the resolvers to avoid unnecessary processing in CSR + .filter((resolver) => { + return ( + // always resolve in SSR + !isPlatformBrowser(this.platformId) || + // resolve in CSR when it's not disabled + !resolver.disabledInCsr || + // resolve in CSR when resolver is enabled in devMode + (isDevMode() && this.pageMetaConfig?.pageMeta?.enableInDevMode) + ); + }) + .forEach( + (resolver) => (resolverMethods[resolver.property] = resolver.method) + ); + } else { + resolverMethods = this.resolverMethods; + } + return resolverMethods; + } + /** * Return the resolver with the best match, based on a score * generated by the resolver. diff --git a/projects/core/src/cms/page/config/default-page-meta.config.ts b/projects/core/src/cms/page/config/default-page-meta.config.ts new file mode 100644 index 00000000000..d4bd9ac45ce --- /dev/null +++ b/projects/core/src/cms/page/config/default-page-meta.config.ts @@ -0,0 +1,33 @@ +import { PageMetaConfig } from './page-meta.config'; + +export const defaultPageMetaConfig: PageMetaConfig = { + pageMeta: { + resolvers: [ + { + property: 'title', + method: 'resolveTitle', + }, + { + property: 'heading', + method: 'resolveHeading', + }, + { + property: 'breadcrumbs', + method: 'resolveBreadcrumbs', + }, + // @TODO (#10467) disable (`disabledInCsr`) description, image and robots resolvers to optimize resolving logic + { + property: 'description', + method: 'resolveDescription', + }, + { + property: 'image', + method: 'resolveImage', + }, + { + property: 'robots', + method: 'resolveRobots', + }, + ], + }, +}; diff --git a/projects/core/src/cms/page/config/index.ts b/projects/core/src/cms/page/config/index.ts new file mode 100644 index 00000000000..a990c613661 --- /dev/null +++ b/projects/core/src/cms/page/config/index.ts @@ -0,0 +1,2 @@ +export * from './default-page-meta.config'; +export * from './page-meta.config'; diff --git a/projects/core/src/cms/page/config/page-meta.config.ts b/projects/core/src/cms/page/config/page-meta.config.ts new file mode 100644 index 00000000000..7f304dccebc --- /dev/null +++ b/projects/core/src/cms/page/config/page-meta.config.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { Config } from '../../../config/config-tokens'; + +@Injectable({ + providedIn: 'root', + useExisting: Config, +}) +export abstract class PageMetaConfig { + pageMeta?: PageMetaResolversConfig; +} + +export interface PageMetaResolversConfig { + resolvers?: PageMetaResolverConfig[]; + + /** + * Enables resolvers in dev mode regardless of the CSR configuration. This + * flag will override the disabling in CSR, which can be useful during development + * and debugging. + */ + enableInDevMode?: boolean; +} + +export interface PageMetaResolverConfig { + /** + * PageMeta property + */ + property: string; + + /** + * The resolver method that must be provided on the resolver class. + */ + method: string; + + /** + * Disables specific resolvers in CSR mode. Some of the resolvers are + * not needed in CSR app, as they're only used for crawlers who will + * be served from SSR rendered pages. + */ + disabledInCsr?: boolean; +} diff --git a/projects/core/src/cms/page/index.ts b/projects/core/src/cms/page/index.ts index cc23d417168..86406c13542 100644 --- a/projects/core/src/cms/page/index.ts +++ b/projects/core/src/cms/page/index.ts @@ -1,4 +1,5 @@ export * from './base-page-meta.resolver'; +export * from './config/index'; export * from './content-page-meta.resolver'; export * from './page-meta.resolver'; export * from './page.resolvers'; diff --git a/projects/core/src/cms/page/page.module.ts b/projects/core/src/cms/page/page.module.ts index d163d92c599..62de4644418 100644 --- a/projects/core/src/cms/page/page.module.ts +++ b/projects/core/src/cms/page/page.module.ts @@ -1,6 +1,8 @@ -import { NgModule } from '@angular/core'; -import { PageMetaResolver } from './page-meta.resolver'; +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { provideDefaultConfig } from '../../config/config-providers'; +import { defaultPageMetaConfig } from './config/default-page-meta.config'; import { ContentPageMetaResolver } from './content-page-meta.resolver'; +import { PageMetaResolver } from './page-meta.resolver'; @NgModule({ providers: [ @@ -11,4 +13,13 @@ import { ContentPageMetaResolver } from './content-page-meta.resolver'; }, ], }) -export class CmsPageTitleModule {} + +// TODO(#10467): Consider renaming to CmsPageModule or PageModule +export class CmsPageTitleModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: CmsPageTitleModule, + providers: [provideDefaultConfig(defaultPageMetaConfig)], + }; + } +} diff --git a/projects/storefrontlib/src/cms-structure/seo/seo-meta.service.ts b/projects/storefrontlib/src/cms-structure/seo/seo-meta.service.ts index 90f6d053386..29fcf2c79d3 100644 --- a/projects/storefrontlib/src/cms-structure/seo/seo-meta.service.ts +++ b/projects/storefrontlib/src/cms-structure/seo/seo-meta.service.ts @@ -27,6 +27,8 @@ export class SeoMetaService implements OnDestroy { this.title = meta.title; this.description = meta.description; this.image = meta.image; + // TODO(#10467): since we only resolve robots on SSR, we should consider to drop the defaults + // with next major, as it's confusing to get the wrong defaults while navigating in CSR. this.robots = meta.robots || [PageRobotsMeta.INDEX, PageRobotsMeta.FOLLOW]; } diff --git a/projects/storefrontlib/src/storefront-config.ts b/projects/storefrontlib/src/storefront-config.ts index 9c7a0216712..3b943e3c597 100644 --- a/projects/storefrontlib/src/storefront-config.ts +++ b/projects/storefrontlib/src/storefront-config.ts @@ -8,6 +8,7 @@ import { GlobalMessageConfig, I18nConfig, OccConfig, + PageMetaConfig, PersonalizationConfig, RoutingConfig, SiteContextConfig, @@ -51,4 +52,5 @@ export type StorefrontConfig = | SkipLinkConfig | PaginationConfig | CartConfig - | SeoConfig; + | SeoConfig + | PageMetaConfig; From d6f7f1c769ed0f53baaab9b771b05e8ca3646ea8 Mon Sep 17 00:00:00 2001 From: Brian Gamboc-Javiniar Date: Mon, 25 Jan 2021 09:47:36 -0500 Subject: [PATCH 19/30] chore: Trigger ll qualtrics-lib by main cms component instead of feature component (#10856) changes lazy loading trigger for qualtrics feature-lib --- feature-libs/qualtrics/root/qualtrics-root.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature-libs/qualtrics/root/qualtrics-root.module.ts b/feature-libs/qualtrics/root/qualtrics-root.module.ts index 2a27c83a634..fcf7e767c84 100644 --- a/feature-libs/qualtrics/root/qualtrics-root.module.ts +++ b/feature-libs/qualtrics/root/qualtrics-root.module.ts @@ -6,7 +6,7 @@ import { provideDefaultConfig } from '@spartacus/core'; provideDefaultConfig({ featureModules: { qualtrics: { - cmsComponents: ['QualtricsEmbeddedFeedbackComponent'], + cmsComponents: ['QualtricsComponent'], }, }, }), From d1749f9219de8893e03fed676647663251d6d8e9 Mon Sep 17 00:00:00 2001 From: Brian Gamboc-Javiniar Date: Mon, 25 Jan 2021 11:41:08 -0500 Subject: [PATCH 20/30] chore: change default Qualtrics path to empty (#10869) - changes default qualtrics script path --- .../qualtrics-loader/config/default-qualtrics-config.ts | 4 +--- .../components/qualtrics-loader/config/qualtrics-config.ts | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/feature-libs/qualtrics/components/qualtrics-loader/config/default-qualtrics-config.ts b/feature-libs/qualtrics/components/qualtrics-loader/config/default-qualtrics-config.ts index 8a075b55c30..9951aaa37ee 100644 --- a/feature-libs/qualtrics/components/qualtrics-loader/config/default-qualtrics-config.ts +++ b/feature-libs/qualtrics/components/qualtrics-loader/config/default-qualtrics-config.ts @@ -1,7 +1,5 @@ import { QualtricsConfig } from './qualtrics-config'; export const defaultQualtricsConfig: QualtricsConfig = { - qualtrics: { - scriptSource: 'assets/qualtrics.js', - }, + qualtrics: {}, }; diff --git a/feature-libs/qualtrics/components/qualtrics-loader/config/qualtrics-config.ts b/feature-libs/qualtrics/components/qualtrics-loader/config/qualtrics-config.ts index 45dfe18dfed..45fdf6c2a0d 100644 --- a/feature-libs/qualtrics/components/qualtrics-loader/config/qualtrics-config.ts +++ b/feature-libs/qualtrics/components/qualtrics-loader/config/qualtrics-config.ts @@ -18,7 +18,6 @@ export abstract class QualtricsConfig { * Deployment script, loaded from a resource, to integrate the deployment of the qualtrics project. * You would typically store the file in the local assets folder. * - * Defaults to `assets/qualtricsIntegration.js` */ scriptSource?: string; }; From fa67781b537cb5b8dfa07eb626ae0422500705dc Mon Sep 17 00:00:00 2001 From: Marcin Lasak Date: Mon, 25 Jan 2021 21:16:21 +0100 Subject: [PATCH 21/30] chore: Script for managing libs dependencies and imports (#10687) Co-authored-by: Gilberto Alvarado --- .github/workflows/config-check.yml | 19 + docs/libs/creating-lib.md | 7 +- .../details/budget-details.component.spec.ts | 3 +- .../permission-details.component.spec.ts | 5 +- .../shared/item-active.directive.spec.ts | 5 +- .../shared/item-exists.directive.spec.ts | 5 +- .../sub-list/sub-list.component.spec.ts | 3 +- .../core/services/b2b-user.service.spec.ts | 3 +- .../core/services/budget.service.spec.ts | 3 +- .../core/services/cost-center.service.spec.ts | 3 +- .../core/services/org-unit.service.spec.ts | 3 +- .../core/services/permission.service.spec.ts | 3 +- package.json | 7 +- tools/config/const.ts | 4 + tools/config/index.ts | 298 +++++ tools/config/manage-dependencies.ts | 1169 +++++++++++++++++ tools/config/tsconfig-paths.ts | 359 +++++ tools/tsconfig-paths/index.ts | 282 ---- yarn.lock | 21 + 19 files changed, 1890 insertions(+), 312 deletions(-) create mode 100644 .github/workflows/config-check.yml create mode 100644 tools/config/const.ts create mode 100644 tools/config/index.ts create mode 100644 tools/config/manage-dependencies.ts create mode 100644 tools/config/tsconfig-paths.ts delete mode 100644 tools/tsconfig-paths/index.ts diff --git a/.github/workflows/config-check.yml b/.github/workflows/config-check.yml new file mode 100644 index 00000000000..6d4b3878046 --- /dev/null +++ b/.github/workflows/config-check.yml @@ -0,0 +1,19 @@ +on: + pull_request: + types: [opened, synchronize, reopened] +name: Config check +jobs: + configCheck: + name: Dependencies and tsconfig files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - uses: actions/setup-node@v2 + with: + node-version: '12' + - name: Yarn + run: yarn + - name: Check configurations + run: yarn config:check + env: + FORCE_COLOR: 2 # Support colors from chalk diff --git a/docs/libs/creating-lib.md b/docs/libs/creating-lib.md index 271b06fa341..63cf836bb88 100644 --- a/docs/libs/creating-lib.md +++ b/docs/libs/creating-lib.md @@ -178,9 +178,8 @@ Use the following template: } ``` -- run `ts-node ./tools/tsconfig-paths/index.ts` script to update `compilerOptions.path` property in tsconfig files +- run `yarn config:update` script to update `compilerOptions.path` property in tsconfig files - `tsconfig.lib.prod.json` - save to re-format it. Make sure that Ivy is off (for the time being, this will change in the future) -- `tsconfig.spec.json` - save to re-format - `tslint.json` - change from `lib` to `cx` in the `directive-selector` and `component-selector` - the rest of the generated file should be removed @@ -266,8 +265,6 @@ Also make sure to add the lib to the `switch` statement at the end of the file. - `sonar-project.properties` - list your library to this file -- `tsconfig.compodoc.json` - add your library to this file - - `projects/schematics/package.json` - add the library to the package group - `scripts/templates/changelog.ejs` - add the library to `const CUSTOM_SORT_ORDER` @@ -309,7 +306,7 @@ This change requires an update in the: - make sure to follow the general folder structure, as seen in e.g. `feature-libs/product` library - add `ng-package.json` to each of the feature folders -- run `ts-node ./tools/tsconfig-paths/index.ts` script to update `compilerOptions.path` property in tsconfig files +- run `yarn config:update` script to update `compilerOptions.path` property in tsconfig files ## Testing diff --git a/feature-libs/organization/administration/components/budget/details/budget-details.component.spec.ts b/feature-libs/organization/administration/components/budget/details/budget-details.component.spec.ts index 51200f0b8e1..72a55036e97 100644 --- a/feature-libs/organization/administration/components/budget/details/budget-details.component.spec.ts +++ b/feature-libs/organization/administration/components/budget/details/budget-details.component.spec.ts @@ -4,8 +4,7 @@ import { RouterTestingModule } from '@angular/router/testing'; import { I18nTestingModule } from '@spartacus/core'; import { Budget } from '@spartacus/organization/administration/core'; import { UrlTestingModule } from 'projects/core/src/routing/configurable-routes/url-translation/testing/url-testing.module'; -import { of } from 'rxjs'; -import { Subject } from 'rxjs/internal/Subject'; +import { of, Subject } from 'rxjs'; import { ItemExistsDirective, MessageService, diff --git a/feature-libs/organization/administration/components/permission/details/permission-details.component.spec.ts b/feature-libs/organization/administration/components/permission/details/permission-details.component.spec.ts index 298cc162570..46d1875a119 100644 --- a/feature-libs/organization/administration/components/permission/details/permission-details.component.spec.ts +++ b/feature-libs/organization/administration/components/permission/details/permission-details.component.spec.ts @@ -4,11 +4,10 @@ import { RouterTestingModule } from '@angular/router/testing'; import { I18nTestingModule } from '@spartacus/core'; import { Permission } from '@spartacus/organization/administration/core'; import { UrlTestingModule } from 'projects/core/src/routing/configurable-routes/url-translation/testing/url-testing.module'; -import { of } from 'rxjs'; -import { Subject } from 'rxjs/internal/Subject'; -import { ItemExistsDirective } from '../../shared/item-exists.directive'; +import { of, Subject } from 'rxjs'; import { CardTestingModule } from '../../shared/card/card.testing.module'; import { ToggleStatusModule } from '../../shared/detail/toggle-status-action/toggle-status.module'; +import { ItemExistsDirective } from '../../shared/item-exists.directive'; import { ItemService } from '../../shared/item.service'; import { MessageTestingModule } from '../../shared/message/message.testing.module'; import { MessageService } from '../../shared/message/services/message.service'; diff --git a/feature-libs/organization/administration/components/shared/item-active.directive.spec.ts b/feature-libs/organization/administration/components/shared/item-active.directive.spec.ts index f8fafcb07ac..e78db045747 100644 --- a/feature-libs/organization/administration/components/shared/item-active.directive.spec.ts +++ b/feature-libs/organization/administration/components/shared/item-active.directive.spec.ts @@ -1,11 +1,10 @@ import { Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of } from 'rxjs/internal/observable/of'; -import { Subject } from 'rxjs/internal/Subject'; +import { GlobalMessageType } from '@spartacus/core'; +import { of, Subject } from 'rxjs'; import { ItemActiveDirective } from './item-active.directive'; import { ItemService } from './item.service'; import { MessageService } from './message/services/message.service'; -import { GlobalMessageType } from '@spartacus/core'; import createSpy = jasmine.createSpy; diff --git a/feature-libs/organization/administration/components/shared/item-exists.directive.spec.ts b/feature-libs/organization/administration/components/shared/item-exists.directive.spec.ts index efdb022c2ba..8403a4e89ec 100644 --- a/feature-libs/organization/administration/components/shared/item-exists.directive.spec.ts +++ b/feature-libs/organization/administration/components/shared/item-exists.directive.spec.ts @@ -1,12 +1,11 @@ import { Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormGroup } from '@angular/forms'; -import { of } from 'rxjs/internal/observable/of'; -import { Subject } from 'rxjs/internal/Subject'; +import { GlobalMessageType } from '@spartacus/core'; +import { of, Subject } from 'rxjs'; import { ItemExistsDirective } from './item-exists.directive'; import { ItemService } from './item.service'; import { MessageService } from './message/services/message.service'; -import { GlobalMessageType } from '@spartacus/core'; import createSpy = jasmine.createSpy; const mockCode = 'mc1'; diff --git a/feature-libs/organization/administration/components/shared/sub-list/sub-list.component.spec.ts b/feature-libs/organization/administration/components/shared/sub-list/sub-list.component.spec.ts index ae459c288fc..ca244a2cae9 100644 --- a/feature-libs/organization/administration/components/shared/sub-list/sub-list.component.spec.ts +++ b/feature-libs/organization/administration/components/shared/sub-list/sub-list.component.spec.ts @@ -1,10 +1,9 @@ import { CommonModule } from '@angular/common'; -import { Component, Input, Output } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { EntitiesModel, I18nTestingModule } from '@spartacus/core'; -import { EventEmitter } from 'events'; import { PaginationTestingModule } from 'projects/storefrontlib/src/shared/components/list-navigation/pagination/testing/pagination-testing.module'; import { of } from 'rxjs'; import { CardTestingModule } from '../card/card.testing.module'; diff --git a/feature-libs/organization/administration/core/services/b2b-user.service.spec.ts b/feature-libs/organization/administration/core/services/b2b-user.service.spec.ts index 61bed3ceb3f..005e76df3fd 100644 --- a/feature-libs/organization/administration/core/services/b2b-user.service.spec.ts +++ b/feature-libs/organization/administration/core/services/b2b-user.service.spec.ts @@ -8,8 +8,7 @@ import { SearchConfig, UserIdService, } from '@spartacus/core'; -import { BehaviorSubject } from 'rxjs'; -import { of } from 'rxjs/internal/observable/of'; +import { BehaviorSubject, of } from 'rxjs'; import { take } from 'rxjs/operators'; import { LoadStatus, diff --git a/feature-libs/organization/administration/core/services/budget.service.spec.ts b/feature-libs/organization/administration/core/services/budget.service.spec.ts index 625bc922d14..31d617473a3 100644 --- a/feature-libs/organization/administration/core/services/budget.service.spec.ts +++ b/feature-libs/organization/administration/core/services/budget.service.spec.ts @@ -1,8 +1,7 @@ import { inject, TestBed } from '@angular/core/testing'; import { Store, StoreModule } from '@ngrx/store'; import { EntitiesModel, SearchConfig, UserIdService } from '@spartacus/core'; -import { BehaviorSubject } from 'rxjs'; -import { of } from 'rxjs/internal/observable/of'; +import { BehaviorSubject, of } from 'rxjs'; import { Budget } from '../model/budget.model'; import { LoadStatus, diff --git a/feature-libs/organization/administration/core/services/cost-center.service.spec.ts b/feature-libs/organization/administration/core/services/cost-center.service.spec.ts index 3c7a2209386..959c55bbf2e 100644 --- a/feature-libs/organization/administration/core/services/cost-center.service.spec.ts +++ b/feature-libs/organization/administration/core/services/cost-center.service.spec.ts @@ -7,8 +7,7 @@ import { SearchConfig, UserIdService, } from '@spartacus/core'; -import { BehaviorSubject } from 'rxjs'; -import { of } from 'rxjs/internal/observable/of'; +import { BehaviorSubject, of } from 'rxjs'; import { take } from 'rxjs/operators'; import { Budget } from '../model/budget.model'; import { diff --git a/feature-libs/organization/administration/core/services/org-unit.service.spec.ts b/feature-libs/organization/administration/core/services/org-unit.service.spec.ts index 1af6dc1eeed..1899f3acee3 100644 --- a/feature-libs/organization/administration/core/services/org-unit.service.spec.ts +++ b/feature-libs/organization/administration/core/services/org-unit.service.spec.ts @@ -12,8 +12,7 @@ import { SearchConfig, UserIdService, } from '@spartacus/core'; -import { BehaviorSubject } from 'rxjs'; -import { of } from 'rxjs/internal/observable/of'; +import { BehaviorSubject, of } from 'rxjs'; import { take } from 'rxjs/operators'; import { LoadStatus, diff --git a/feature-libs/organization/administration/core/services/permission.service.spec.ts b/feature-libs/organization/administration/core/services/permission.service.spec.ts index 48611715916..0aa61f2df96 100644 --- a/feature-libs/organization/administration/core/services/permission.service.spec.ts +++ b/feature-libs/organization/administration/core/services/permission.service.spec.ts @@ -7,8 +7,7 @@ import { SearchConfig, UserIdService, } from '@spartacus/core'; -import { BehaviorSubject } from 'rxjs'; -import { of } from 'rxjs/internal/observable/of'; +import { BehaviorSubject, of } from 'rxjs'; import { take } from 'rxjs/operators'; import { LoadStatus, diff --git a/package.json b/package.json index 3cfbbc4f668..ccd0e7c2f1f 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,8 @@ "e2e:cy:b2b:start-run-ci:2005": "start-server-and-test start:ci:b2b http-get://localhost:4200 e2e:cy:run:b2b:ci", "start:ci:b2b": "cross-env SPARTACUS_BASE_URL=https://spartacus-devci767.eastus.cloudapp.azure.com:9002 SPARTACUS_API_PREFIX=/occ/v2/ SPARTACUS_B2B=true yarn start", "e2e:cy:open:b2b": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:open:b2b", - "config:update": "ts-node ./tools/tsconfig-paths/index.ts" + "config:update": "ts-node ./tools/config/index.ts --fix", + "config:check": "ts-node ./tools/config/index.ts" }, "private": false, "dependencies": { @@ -161,7 +162,7 @@ "@types/node": "^12.11.1", "@types/parse5": "^5.0.3", "@types/shelljs": "^0.8.7", - "chalk": "^2.4.2", + "chalk": "^4.1.0", "codelyzer": "^6.0.0", "commander": "^3.0.0", "concurrently": "^5.3.0", @@ -190,6 +191,8 @@ "karma-junit-reporter": "^1.2.0", "ng-packagr": "^10.1.0", "npm-package-versions": "^1.0.1", + "postcss": "^8.2.3", + "postcss-scss": "^3.0.4", "prettier": "~2.1.1", "release-it": "^14.2.2", "rimraf": "^3.0.2", diff --git a/tools/config/const.ts b/tools/config/const.ts new file mode 100644 index 00000000000..9d7b095e7c2 --- /dev/null +++ b/tools/config/const.ts @@ -0,0 +1,4 @@ +export const PACKAGE_JSON = 'package.json'; +export const NG_PACKAGE_JSON = 'ng-package.json'; +export const SPARTACUS_SCOPE = '@spartacus'; +export const SPARTACUS_SCHEMATICS = `${SPARTACUS_SCOPE}/schematics`; diff --git a/tools/config/index.ts b/tools/config/index.ts new file mode 100644 index 00000000000..fe241fb05bd --- /dev/null +++ b/tools/config/index.ts @@ -0,0 +1,298 @@ +/** + * Set of tools for managing configuration in this repository + * + * Currently: + * - sets paths in tsconfig files + * - manage dependencies and their versions in libraries + * + * To do: + * - sonar cloud configuration + */ + +import chalk from 'chalk'; +import { execSync } from 'child_process'; +import { Command } from 'commander'; +import { readFileSync } from 'fs'; +import glob from 'glob'; +import { NG_PACKAGE_JSON, PACKAGE_JSON } from './const'; +import { manageDependencies } from './manage-dependencies'; +import { manageTsConfigs } from './tsconfig-paths'; + +// ------------ Utilities ------------ + +/** + * Logger when everything was ok + */ +export function success(message?: string): void { + if (options.fix) { + console.log(chalk.green(message ?? ' ✔ Nothing to update')); + } else { + console.log(chalk.green(message ?? ' ✔ No problems found')); + } +} + +/** + * Log updated file + * + * @param path updated file path + */ +export function logUpdatedFile(path: string): void { + console.log(chalk.green(` ✔ File \`${chalk.bold(path)}\` updated`)); +} + +/** + * Log violation (warning,error) for file + * + * @param file related file + * @param errors violation + * @param help help information + */ +function logViolation( + file: string, + violation: string, + [help, ...extraHelp]: string[] +): void { + let minLength = 76; + console.log(` +${chalk.gray( + `--- ${file} ${`-`.repeat(Math.max(0, minLength - file.length - 1))}` +)} +${violation} + +${chalk.blue(`${chalk.bold(' i ')}${help}`)}${extraHelp + .map((help) => chalk.blue(`\n ${help}`)) + .join('')} +${chalk.gray(`----${`-`.repeat(Math.max(file.length, minLength))}`)} +`); +} + +/** + * Print error + * + * @param file related file + * @param errors list of errors + * @param help help information + */ +export function error(file: string, errors: string[], help: string[]): void { + errorsCount += errors.length; + logViolation( + file, + errors.map((error) => chalk.red(` ✖ ${error}`)).join('\n'), + help + ); +} + +/** + * Print warning + * + * @param file related file + * @param warnings list of warnings + * @param help help information + */ +export function warning( + file: string, + warnings: string[], + help: string[] +): void { + warningsCount += warnings.length; + logViolation( + file, + warnings.map((warning) => chalk.yellow(` ⚠ ${warning}`)).join('\n'), + help + ); +} + +/** + * Read content of json file + * + * @param path file path + */ +function readJsonFile(path: string): any { + return JSON.parse(readFileSync(path, 'utf-8')); +} + +/** + * Log script step + * + * @param message step to log + */ +export function reportProgress(message: string): void { + console.log(`\n${message}`); +} + +// ------------ Utilities end ------------ + +const program = new Command(); +program + .description('Check configuration in repository for inconsistencies') + .option('--fix', 'Apply automatic fixes when possible') + .option('--breaking-changes', 'Allow breaking changes in transformations'); + +program.parse(process.argv); + +let errorsCount = 0; +let warningsCount = 0; + +/** + * Options you can pass to the script + */ +export type ProgramOptions = { + /** + * Defines if the script should be run in fix mode instead of check + */ + fix: boolean | undefined; + /** + * Sets if also breaking changes should be applied. Use for majors only. + */ + breakingChanges: boolean | undefined; +}; + +const options: ProgramOptions = program.opts() as any; + +/** + * Type to match package.json structure + */ +export type PackageJson = { + /** + * Package name + */ + name: string; + /** + * Package version + */ + version: string; + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; +}; + +export type Library = { + /** + * Library name + */ + name: string; + /** + * Library version + */ + version: string; + /** + * Directory of the library + */ + directory: string; + /** + * Directory where the library lands + */ + distDir: string; + /** + * Content of the library package.json + */ + packageJsonContent: PackageJson; + dependencies: Record; + devDependencies: Record; + optionalDependencies: Record; + peerDependencies: Record; + /** + * Entry points in the library + */ + entryPoints: Array<{ + /** + * Name of the entry point eg. @spartacus/asm/core + */ + entryPoint: string; + /** + * Directory of the entry point relative to library directory eg. ./core + */ + directory: string; + /** + * Entry file for the entry point. Eg. ./public_api + */ + entryFile: string; + }>; +}; + +export type Repository = { + [library: string]: Library; +}; + +/** + * Paths to `package.json` files for all libraries. + */ +const librariesPaths = glob.sync( + `{core-libs,feature-libs,integration-libs,projects}/!(node_modules)/${PACKAGE_JSON}`, + { + ignore: [ + `projects/storefrontapp-e2e-cypress/${PACKAGE_JSON}`, + `projects/dev-schematics/${PACKAGE_JSON}`, + `projects/storefrontapp/${PACKAGE_JSON}`, + ], + } +); + +// Representation of repository content and structure +const repository = librariesPaths + .map((libraryPath) => { + const packageJson: PackageJson = readJsonFile(libraryPath); + const directory = libraryPath.substring( + 0, + libraryPath.length - `/${PACKAGE_JSON}`.length + ); + + const ngPackageFilesPaths = glob.sync(`${directory}/**/${NG_PACKAGE_JSON}`); + const entryPoints = ngPackageFilesPaths.map((ngPackagePath) => { + const ngPackageFileContent = readJsonFile(ngPackagePath); + let pathWithoutLibDirectory = ngPackagePath.substring(directory.length); + let pathWithoutNgPackage = pathWithoutLibDirectory.substring( + 0, + pathWithoutLibDirectory.length - `/${NG_PACKAGE_JSON}`.length + ); + return { + entryPoint: `${packageJson.name}${pathWithoutNgPackage}`, + directory: `${pathWithoutNgPackage}`, + entryFile: `${ngPackageFileContent.lib.entryFile.replace('.ts', '')}`, + }; + }); + + return { + name: packageJson.name as string, + packageJsonContent: packageJson, + version: packageJson.version as string, + directory, + distDir: directory.split('/')[1], + dependencies: packageJson.dependencies ?? {}, + devDependencies: packageJson.devDependencies ?? {}, + peerDependencies: packageJson.peerDependencies ?? {}, + optionalDependencies: packageJson.optionalDependencies ?? {}, + entryPoints, + }; + }) + .reduce((acc: Repository, library) => { + acc[library.name] = library; + return acc; + }, {}); + +manageDependencies(repository, options); +// Keep it after dependencies, because fixes from deps might might result in different tsconfig files +manageTsConfigs(repository, options); + +/** + * Format all files. + */ +if (options.fix) { + console.log('\nFormatting files (might take some time)...\n'); + execSync('yarn prettier:fix'); + console.log(`✨ ${chalk.green('Update completed')}`); +} + +/** + * Log total number of errors and warnings when there are some + */ +if (!options.fix && (errorsCount > 0 || warningsCount > 0)) { + console.log(chalk.red(`\nErrors: ${errorsCount}`)); + console.log(chalk.yellow(`Warnings: ${warningsCount}\n`)); +} + +// Fail script when there are some errors +if (errorsCount > 0) { + process.exitCode = 1; +} diff --git a/tools/config/manage-dependencies.ts b/tools/config/manage-dependencies.ts new file mode 100644 index 00000000000..895656d4bdd --- /dev/null +++ b/tools/config/manage-dependencies.ts @@ -0,0 +1,1169 @@ +/** + * Algorithm for managing dependencies in spartacus + * + * Assumptions: + * - all dependencies for packages and app are stored only in top level node_modules + * - internal tooling (cypress, github actions) can have separate node_modules + * - if library have import in any *.ts, *.scss file to dependency it should be specified as peer (optional) dependency in lib package.json + * - @spartacus/* peer deps should match version in their package.json + * - deps version should match version in the root package.json + * - all TS packages should have dependency on tslib + * - all used deps must be listed in root package.json + * - schematics should also have version synced from the root package.json and library list of dependencies (should be done in schematics) + */ + +import chalk from 'chalk'; +import { execSync } from 'child_process'; +import fs, { readFileSync } from 'fs'; +import glob from 'glob'; +import postcss from 'postcss-scss'; +import semver from 'semver'; +import * as ts from 'typescript'; +import { PACKAGE_JSON, SPARTACUS_SCOPE } from './const'; +import { + error, + Library, + logUpdatedFile, + PackageJson, + ProgramOptions, + reportProgress, + Repository, + success, + warning, +} from './index'; + +// ------------ Utilities ------------ + +/** + * Read and parse json file content + * + * @param path json file path + * + * @returns content of the json + */ +function readJsonFile(path: string): any { + return JSON.parse(fs.readFileSync(path, 'utf-8')); +} + +/** + * Stringify and safe json file content + * + * @param path json file path + * @param content content to save + */ +function saveJsonFile(path: string, content: any): void { + fs.writeFileSync(path, JSON.stringify(content, undefined, 2)); +} + +/** + * Get list of all imports (paths) + * + * @param sourceFile ts source file + * + * @returns set with import paths + */ +function getAllImports(sourceFile: ts.SourceFile): Set { + const imports = new Set(); + delintNode(sourceFile); + + function delintNode(node: ts.Node) { + switch (node.kind) { + case ts.SyntaxKind.ImportDeclaration: + const imp = node as ts.ImportDeclaration; + const text = imp.moduleSpecifier.getText(); + imports.add(text.replace(/['"]+/g, '')); + break; + } + + ts.forEachChild(node, delintNode); + } + return imports; +} + +// ------------ Utilities end ------------ + +interface LibraryWithDependencies extends Library { + tsImports: { + [importPath: string]: { + importPath: string; + files: Set; + usageIn: { + spec: boolean; + lib: boolean; + schematics: boolean; + schematicsSpec: boolean; + }; + }; + }; + scssImports: { + [importPath: string]: { + importPath: string; + files: Set; + }; + }; + externalDependencies: { + [dependency: string]: { + dependency: string; + files: Set; + usageIn: { + spec: boolean; + lib: boolean; + schematics: boolean; + schematicsSpec: boolean; + styles: boolean; + }; + }; + }; + externalDependenciesForPackageJson: { + [dependency: string]: { + dependency: string; + files: Set; + usageIn: { + spec: boolean; + lib: boolean; + schematics: boolean; + schematicsSpec: boolean; + styles: boolean; + }; + }; + }; +} + +export function manageDependencies( + repository: Repository, + options: ProgramOptions +) { + const libraries = Object.values(repository) + .map( + (library: Library): LibraryWithDependencies => { + const tsImports = {}; + const scssImports = {}; + + // Gather data about ts imports + const tsFilesPaths = glob.sync(`${library.directory}/**/*.ts`); + + tsFilesPaths.forEach((fileName) => { + const sourceFile = ts.createSourceFile( + fileName, + readFileSync(fileName).toString(), + ts.ScriptTarget.ES2015, + true + ); + + const fileImports = getAllImports(sourceFile); + fileImports.forEach((val) => { + if (tsImports[val]) { + tsImports[val].files.add(fileName); + } else { + tsImports[val] = { + importPath: val, + files: new Set([fileName]), + usageIn: { + spec: false, + lib: false, + schematics: false, + schematicsSpec: false, + }, + }; + } + }); + }); + + // Gather data about scss imports + const scssFilesPaths = glob.sync(`${library.directory}/**/*.scss`); + + scssFilesPaths.forEach((fileName) => { + const ast = postcss.parse(readFileSync(fileName).toString()); + const imports = new Set(); + ast.walk((node) => { + if (node.type === 'atrule' && node.name === 'import') { + const path = node.params.replace(/['"]+/g, ''); + imports.add(path); + } + }); + imports.forEach((val) => { + if (scssImports[val]) { + scssImports[val].files.add(fileName); + } else { + scssImports[val] = { + importPath: val, + files: new Set([fileName]), + }; + } + }); + }); + + return { + ...library, + scssImports, + tsImports, + externalDependencies: {}, + externalDependenciesForPackageJson: {}, + }; + } + ) + .reduce((acc: Record, curr) => { + acc[curr.name] = curr; + return acc; + }, {}); + + // Check where imports are used (spec, lib, schematics, schematics spec) + categorizeUsageOfDependencies(libraries); + + // Check for rxjs/internal imports + checkRxInternalImports(libraries, options); + + // Filter out al the relative paths for our source code + filterLocalRelativeImports(libraries); + + // Check if we use node dependencies where we should not, then filter native Node imports. + filterNativeNodeAPIs(libraries, options); + + // Check if we use absolute paths where we should not, then filter these. + filterLocalAbsolutePathFiles(libraries, options); + + // Define list of external dependencies for each library + extractExternalDependenciesFromImports(libraries); + + const rootPackageJsonContent: PackageJson = readJsonFile(`./${PACKAGE_JSON}`); + + // Check if we have all dependencies directly referenced in root package.json + checkIfWeHaveAllDependenciesInPackageJson( + libraries, + rootPackageJsonContent, + options + ); + + // Filer out spec dependencies as we already checked everything related to them + filterOutSpecOnlyDependencies(libraries); + + // Add to lib package.json missing dependencies + addMissingDependenciesToPackageJson( + libraries, + rootPackageJsonContent, + options + ); + + // Remove unused dependencies from libraries package.json files + removeNotUsedDependenciesFromPackageJson(libraries, options); + + // Make sure that libraries don't have devDependencies + checkEmptyDevDependencies(libraries, options); + + // Check if all TS packages have dependency on tslib + checkTsLibDep(libraries, rootPackageJsonContent, options); + + // Check if libraries doesn't use local node_modules + checkForLockFile(libraries, options); + + // Synchronize version in libraries dependencies to root package.json + updateDependenciesVersions(libraries, rootPackageJsonContent, options); + + if (options.fix) { + savePackageJsonFiles(libraries); + } +} + +/** + * Remove these imports from the object as it is no longer useful in processing. + */ +function filterLocalRelativeImports( + libraries: Record +): void { + Object.values(libraries).forEach((lib) => { + lib.tsImports = Object.values(lib.tsImports) + .filter((imp) => !imp.importPath.startsWith('.')) + .reduce((acc, curr) => { + acc[curr.importPath] = curr; + return acc; + }, {}); + lib.scssImports = Object.values(lib.scssImports) + .filter( + (imp) => + imp.importPath.startsWith('node_modules/') || + imp.importPath.startsWith('~') + ) + .reduce((acc, curr) => { + acc[curr.importPath] = curr; + return acc; + }, {}); + }); +} + +/** + * Filter native Node.js APIs + */ +function filterNativeNodeAPIs( + libraries: Record, + options: ProgramOptions +): void { + const nodeAPIs = [ + 'fs', + 'fs/promise', + 'path', + 'events', + 'assert', + 'async_hooks', + 'child_process', + 'cluster', + 'crypto', + 'diagnostics_channel', + 'dns', + 'domain', + 'http', + 'http2', + 'https', + 'inspector', + 'net', + 'os', + 'perf_hooks', + 'process', + 'punycode', + 'querystring', + 'readline', + 'repl', + 'stream', + 'string_decoder', + 'tls', + 'trace_events', + 'tty', + 'dgram', + 'url', + 'util', + 'v8', + 'vm', + 'wasi', + 'worker_threads', + 'zlib', + ]; + + if (!options.fix) { + reportProgress('Checking imports of Node.js APIs'); + + let errorsFound = false; + Object.values(libraries).forEach((lib) => { + lib.tsImports = Object.values(lib.tsImports) + .filter((imp) => { + if (nodeAPIs.includes(imp.importPath)) { + // Don't run the check in fix mode + if (!options.fix) { + // Don't allow to use node api outside of schematics spec files + if ( + imp.usageIn.spec || + imp.usageIn.lib || + imp.usageIn.schematics + ) { + imp.files.forEach((file) => { + // Allow to use Node APIs in SSR + if (!file.includes('ssr')) { + errorsFound = true; + error( + file, + [ + `Node.js API \`${chalk.bold( + imp.importPath + )}\` is referenced.`, + ], + [ + `Node.js APIs can only be used in SSR code or in schematics specs.`, + `You might have wanted to import it from some library instead.`, + ] + ); + } + }); + } + } + return false; + } + return true; + }) + .reduce((acc, curr) => { + acc[curr.importPath] = curr; + return acc; + }, {}); + }); + if (!errorsFound) { + success(); + } + } +} + +/** + * Filer native Node.js APIs + */ +function filterLocalAbsolutePathFiles( + libraries: Record, + options: ProgramOptions +): void { + if (!options.fix) { + reportProgress('Checking absolute path imports'); + + let errorsFound = false; + Object.values(libraries).forEach((lib) => { + lib.tsImports = Object.values(lib.tsImports) + .filter((imp) => { + if ( + fs.existsSync(imp.importPath) || + fs.existsSync(`${imp.importPath}.ts`) + ) { + // Don't run in fix mode + if (!options.fix) { + // Allow to use absolute paths for spec files + if ( + imp.usageIn.lib || + imp.usageIn.schematics || + imp.usageIn.schematicsSpec + ) { + imp.files.forEach((file) => { + errorsFound = true; + error( + file, + [ + `Absolute import should not be used outside of spec files.\n Referenced \`${chalk.bold( + imp.importPath + )}\``, + ], + [ + `You can use absolute paths only in test files.`, + `Use relative or entry point import instead.`, + ] + ); + }); + } + } + return false; + } + return true; + }) + .reduce((acc, curr) => { + acc[curr.importPath] = curr; + return acc; + }, {}); + }); + if (!errorsFound) { + success(); + } + } +} + +/** + * Categorize in which type of files we use different dependencies + */ +function categorizeUsageOfDependencies( + libraries: Record +): void { + Object.values(libraries).forEach((lib) => { + Object.values(lib.tsImports).forEach((imp) => { + imp.files.forEach((file) => { + if (file.endsWith('_spec.ts')) { + imp.usageIn.schematicsSpec = true; + } else if ( + file.endsWith('spec.ts') || + file === `${lib.directory}/test.ts` || + file === `${lib.directory}/src/test.ts` + ) { + imp.usageIn.spec = true; + } else if (file.includes('schematics')) { + imp.usageIn.schematics = true; + } else { + imp.usageIn.lib = true; + } + }); + }); + }); +} + +/** + * Check rxjs/internal imports + */ +function checkRxInternalImports( + libraries: Record, + options: ProgramOptions +): void { + if (!options.fix) { + reportProgress('Checking `rx/internal` imports'); + let errorsFound = false; + Object.values(libraries).forEach((lib) => { + Object.values(lib.tsImports).forEach((imp) => { + if (imp.importPath.includes('rxjs/internal')) { + imp.files.forEach((file) => { + errorsFound = true; + error( + file, + [`\`${chalk.bold(imp.importPath)}\` internal import used.`], + [ + `To import from rxjs library you should use \`${chalk.bold( + 'rxjs' + )}\` or \`${chalk.bold('rxjs/operators')}\` imports.`, + ] + ); + }); + } + }); + }); + if (!errorsFound) { + success(); + } + } +} + +/** + * Extract external dependencies from imports + */ +function extractExternalDependenciesFromImports( + libraries: Record +): void { + Object.values(libraries).forEach((lib) => { + Object.values(lib.tsImports).forEach((imp) => { + let dependency; + if (imp.importPath.startsWith('@')) { + const [scope, name] = imp.importPath.split('/'); + dependency = `${scope}/${name}`; + } else { + const [name] = imp.importPath.split('/'); + dependency = `${name}`; + } + + if (!lib.externalDependencies[dependency]) { + lib.externalDependencies[dependency] = { + dependency, + files: new Set(), + usageIn: { + lib: false, + spec: false, + schematics: false, + schematicsSpec: false, + styles: false, + }, + }; + } + imp.files.forEach((file) => { + lib.externalDependencies[dependency].files.add(file); + }); + if (imp.usageIn.lib) { + lib.externalDependencies[dependency].usageIn.lib = true; + } + if (imp.usageIn.schematics) { + lib.externalDependencies[dependency].usageIn.schematics = true; + } + if (imp.usageIn.schematicsSpec) { + lib.externalDependencies[dependency].usageIn.schematicsSpec = true; + } + if (imp.usageIn.spec) { + lib.externalDependencies[dependency].usageIn.spec = true; + } + }); + Object.values(lib.scssImports).forEach((imp) => { + let dependency; + let dep; + if (imp.importPath.startsWith('~')) { + dep = imp.importPath.substring(1); + } else { + dep = imp.importPath.substring('node_modules/'.length); + } + if (dep.startsWith('@')) { + const [scope, name] = dep.split('/'); + dependency = `${scope}/${name}`; + } else { + const [name] = dep.split('/'); + dependency = `${name}`; + } + + if (!lib.externalDependencies[dependency]) { + lib.externalDependencies[dependency] = { + dependency, + files: new Set(), + usageIn: { + lib: false, + spec: false, + schematics: false, + schematicsSpec: false, + styles: false, + }, + }; + } + imp.files.forEach((file) => { + lib.externalDependencies[dependency].files.add(file); + }); + lib.externalDependencies[dependency].usageIn.styles = true; + }); + }); +} + +/** + * Get external dependencies from imports + */ +function checkIfWeHaveAllDependenciesInPackageJson( + libraries: Record, + packageJson: PackageJson, + options: ProgramOptions +): void { + if (!options.fix) { + const allDeps = { + ...packageJson.devDependencies, + ...packageJson.dependencies, + }; + const errors = []; + reportProgress(`Checking for missing dependencies in root ${PACKAGE_JSON}`); + Object.values(libraries).forEach((lib) => { + Object.values(lib.externalDependencies).forEach((dep) => { + if ( + !dep.dependency.startsWith(`${SPARTACUS_SCOPE}/`) && + !Object.keys(allDeps).includes(dep.dependency) + ) { + errors.push( + `Missing \`${chalk.bold( + dep.dependency + )}\` dependency that is used directly in \`${chalk.bold( + lib.name + )}\`.` + ); + } + }); + }); + if (errors.length) { + error(PACKAGE_JSON, errors, [ + `All dependencies that are directly referenced should be specified as \`${chalk.bold( + 'dependencies' + )}\` or \`${chalk.bold('devDependencies')}\`.`, + `Install them with \`${chalk.bold( + 'yarn add [--dev]' + )}\`.`, + ]); + } else { + success(); + } + } +} + +/** + * Remove spec external dependencies as we already took care of them. + */ +function filterOutSpecOnlyDependencies( + libraries: Record +): void { + Object.values(libraries).forEach((lib) => { + lib.externalDependenciesForPackageJson = Object.values( + lib.externalDependencies + ) + .filter((dep) => { + if ( + !dep.usageIn.lib && + !dep.usageIn.schematics && + !dep.usageIn.styles + ) { + return false; + } + return true; + }) + .reduce((acc, curr) => { + acc[curr.dependency] = curr; + return acc; + }, {}); + }); +} + +/** + * Adds peerDependencies to libraries package.json files + */ +function addMissingDependenciesToPackageJson( + libraries: Record, + rootPackageJson: PackageJson, + options: ProgramOptions +): void { + const deps = { + ...rootPackageJson.dependencies, + ...rootPackageJson.devDependencies, + }; + if (options.fix) { + reportProgress('Updating missing peerDependencies'); + } else { + reportProgress('Checking for missing peerDependencies'); + } + const updates = new Set(); + let errorsFound = false; + Object.values(libraries).forEach((lib) => { + const pathToPackageJson = `${lib.directory}/${PACKAGE_JSON}`; + const errors = []; + Object.values(lib.externalDependenciesForPackageJson).forEach((dep) => { + if ( + typeof lib.dependencies[dep.dependency] === 'undefined' && + typeof lib.peerDependencies[dep.dependency] === 'undefined' && + typeof lib.optionalDependencies[dep.dependency] === 'undefined' && + dep.dependency !== lib.name + ) { + if (options.fix) { + const packageJson = lib.packageJsonContent; + const version = deps[dep.dependency]; + if ( + typeof version === 'undefined' && + !dep.dependency.startsWith(`${SPARTACUS_SCOPE}/`) + ) { + // Nothing we can do here. First the dependencies must be added to root package.json (previous check). + } else { + if (typeof packageJson.peerDependencies === 'undefined') { + packageJson.peerDependencies = {}; + } + if (typeof version !== 'undefined') { + packageJson.peerDependencies[dep.dependency] = version; + updates.add(pathToPackageJson); + } else if (dep.dependency !== lib.name) { + packageJson.peerDependencies[dep.dependency] = + libraries[dep.dependency].version; + updates.add(pathToPackageJson); + } + } + } else { + errors.push( + `Missing \`${chalk.bold( + dep.dependency + )}\` dependency that is directly referenced in library.` + ); + } + } + }); + if (errors.length) { + errorsFound = true; + error(pathToPackageJson, errors, [ + `All dependencies that are directly referenced should be specified as \`${chalk.bold( + 'dependencies' + )}\` or \`${chalk.bold('peerDependencies')}\`.`, + `Adding new \`${chalk.bold( + 'peerDependency' + )}\` might be a breaking change!`, + `This can be automatically fixed by running \`${chalk.bold( + 'yarn config:update' + )}\`.`, + ]); + } + }); + if (options.fix) { + if (updates.size > 0) { + updates.forEach((packageJsonPath) => { + logUpdatedFile(packageJsonPath); + }); + } else { + success(); + } + } else if (!errorsFound) { + success(); + } +} + +/** + * Remove dependencies that are no longer referenced in the library + */ +function removeNotUsedDependenciesFromPackageJson( + libraries: Record, + options: ProgramOptions +): void { + if (options.fix) { + reportProgress('Removing unused dependencies'); + } else { + reportProgress('Checking unused dependencies'); + } + const updates = new Set(); + let errorsFound = false; + Object.values(libraries).forEach((lib) => { + const deps = { + ...lib.dependencies, + ...lib.peerDependencies, + ...lib.optionalDependencies, + }; + const errors = []; + const pathToPackageJson = `${lib.directory}/${PACKAGE_JSON}`; + Object.keys(deps).forEach((dep) => { + if ( + typeof lib.externalDependenciesForPackageJson[dep] === 'undefined' && + dep !== `tslib` + ) { + if (options.fix) { + const packageJson = lib.packageJsonContent; + if (typeof packageJson?.dependencies?.[dep] !== 'undefined') { + delete packageJson.dependencies[dep]; + updates.add(pathToPackageJson); + } else if ( + typeof packageJson?.peerDependencies?.[dep] !== 'undefined' + ) { + delete packageJson.peerDependencies[dep]; + updates.add(pathToPackageJson); + } else if ( + typeof packageJson?.optionalDependencies?.[dep] !== 'undefined' + ) { + delete packageJson.optionalDependencies[dep]; + updates.add(pathToPackageJson); + } + } else { + errors.push( + `Dependency \`${chalk.bold( + dep + )}\` is not used in the \`${chalk.bold(lib.name)}\`.` + ); + } + } + }); + if (errors.length > 0) { + errorsFound = true; + error(pathToPackageJson, errors, [ + `Dependencies that are not used should not be specified in package list of \`${chalk.bold( + 'dependencies' + )}\` or \`${chalk.bold('peerDependencies')}\`.`, + `This can be automatically fixed by running \`${chalk.bold( + 'yarn config:update' + )}\`.`, + ]); + } + }); + if (options.fix) { + if (updates.size > 0) { + updates.forEach((packageJsonPath) => { + logUpdatedFile(packageJsonPath); + }); + } else { + success(); + } + } else if (!errorsFound) { + success(); + } +} + +/** + * Check if package does not have any devDependencies + */ +function checkEmptyDevDependencies( + libraries: Record, + options: ProgramOptions +): void { + if (!options.fix) { + reportProgress('Checking unnecessary `devDependencies`'); + let errorsFound = false; + Object.values(libraries).forEach((lib) => { + if (Object.keys(lib.devDependencies).length > 0) { + const pathToPackageJson = `${lib.directory}/${PACKAGE_JSON}`; + errorsFound = true; + error( + pathToPackageJson, + [ + `Libraries should not have \`${chalk.bold( + 'devDependencies' + )}\` specified in their ${PACKAGE_JSON}.`, + ], + [ + `You should use \`${chalk.bold( + 'devDependencies' + )}\` from root ${PACKAGE_JSON} file.`, + `You should remove this section from this ${PACKAGE_JSON}.`, + ] + ); + } + }); + if (!errorsFound) { + success(); + } + } +} + +// Check if we have everywhere tslib dependency +function checkTsLibDep( + libraries: Record, + rootPackageJson: PackageJson, + options: ProgramOptions +): void { + const tsLibName = 'tslib'; + const tsLibVersion = rootPackageJson.dependencies[tsLibName]; + if (options.fix) { + reportProgress(`Updating \`${tsLibName}\` dependency usage`); + } else { + reportProgress(`Checking \`${tsLibName}\` dependency usage`); + } + const updates = new Set(); + let errorsFound = false; + Object.values(libraries).forEach((lib) => { + // Styles library is the only library without TS + if (lib.name !== `${SPARTACUS_SCOPE}/styles`) { + const pathToPackageJson = `${lib.directory}/${PACKAGE_JSON}`; + const errors = []; + if (!Object.keys(lib.dependencies).includes(tsLibName)) { + if (options.fix) { + const packageJson = lib.packageJsonContent; + if (typeof packageJson?.dependencies === 'undefined') { + packageJson.dependencies = {}; + } + packageJson.dependencies[tsLibName] = tsLibVersion; + updates.add(pathToPackageJson); + } else { + errors.push( + `Missing \`${chalk.bold(tsLibName)}\` dependency in \`${chalk.bold( + 'dependencies' + )}\` list.` + ); + } + } + if (Object.keys(lib.peerDependencies).includes(tsLibName)) { + if (options.fix) { + const packageJson = lib.packageJsonContent; + delete packageJson.peerDependencies[tsLibName]; + updates.add(pathToPackageJson); + } else { + errors.push( + `Dependency \`${chalk.bold( + tsLibName + )}\` should be in \`${chalk.bold( + 'dependencies' + )}\` list. Not in the \`${chalk.bold('peerDependencies')}\`.` + ); + } + } + if (Object.keys(lib.optionalDependencies).includes(tsLibName)) { + if (options.fix) { + const packageJson = lib.packageJsonContent; + delete packageJson.optionalDependencies[tsLibName]; + updates.add(pathToPackageJson); + } else { + errors.push( + `Dependency \`${chalk.bold( + tsLibName + )}\` should be in \`${chalk.bold( + 'dependencies' + )}\` list. Not in the \`${chalk.bold('optionalDependency')}\`.` + ); + } + } + if (errors.length > 0) { + errorsFound = true; + error(pathToPackageJson, errors, [ + `Each TS package should have \`${chalk.bold( + tsLibName + )}\` specified as \`${chalk.bold('dependency')}.`, + `This can be automatically fixed by running \`${chalk.bold( + 'yarn config:update' + )}\`.`, + ]); + } + } + }); + if (options.fix) { + if (updates.size > 0) { + updates.forEach((packageJsonPath) => { + logUpdatedFile(packageJsonPath); + }); + } else { + success(); + } + } else if (!errorsFound) { + success(); + } +} + +function checkForLockFile( + libraries: Record, + options: ProgramOptions +): void { + if (!options.fix) { + reportProgress('Checking for unnecessary `yarn.lock` files'); + let errorsFound = false; + Object.values(libraries).forEach((lib) => { + const lockFile = glob.sync(`${lib.directory}/yarn.lock`); + if (lockFile.length > 0) { + errorsFound = true; + error( + lockFile[0], + [ + `Library \`${chalk.bold( + lib.name + )}\` should not have its own \`${chalk.bold('yarn.lock')}\`.`, + ], + [ + `Libraries should use packages from root \`${chalk.bold( + PACKAGE_JSON + )}\` and root \`${chalk.bold('node_modules')}\`.`, + ] + ); + } + }); + if (!errorsFound) { + success(); + } + } +} + +/** + * Update versions in all libraries package json files + */ +function updateDependenciesVersions( + libraries: Record, + rootPackageJson: PackageJson, + options: ProgramOptions +): void { + const rootDeps = { + ...rootPackageJson.dependencies, + ...rootPackageJson.devDependencies, + }; + if (options.fix) { + reportProgress( + `Updating packages versions between libraries and root ${PACKAGE_JSON}` + ); + } else { + reportProgress( + `Checking packages versions between libraries and root ${PACKAGE_JSON}` + ); + } + const updates = new Set(); + let errorsFound = false; + Object.values(libraries).forEach((lib) => { + const pathToPackageJson = `${lib.directory}/${PACKAGE_JSON}`; + const packageJson = lib.packageJsonContent; + const types = ['dependencies', 'peerDependencies', 'optionalDependencies']; + const errors = []; + const internalErrors = []; + const breakingErrors = []; + types.forEach((type) => { + Object.keys(packageJson[type] ?? {}).forEach((dep) => { + if (!semver.validRange(packageJson[type][dep])) { + if (!options.fix) { + errorsFound = true; + error( + pathToPackageJson, + [ + `Package \`${chalk.bold( + packageJson[type][dep] + )}\` version is not correct.`, + ], + [`Install package version that follows semver.`] + ); + } + return; + } + if (dep.startsWith(SPARTACUS_SCOPE)) { + if (packageJson[type][dep] !== libraries[dep].version) { + if (options.fix) { + packageJson[type][dep] = libraries[dep].version; + updates.add(pathToPackageJson); + } else { + internalErrors.push( + `Dependency \`${chalk.bold( + dep + )}\` have different version \`${chalk.bold( + packageJson[type][dep] + )}\` than the package in repository \`${chalk.bold( + libraries[dep].version + )}\`.` + ); + } + } + } else if ( + typeof rootDeps[dep] !== 'undefined' && + packageJson[type][dep] !== rootDeps[dep] + ) { + // Careful with breaking changes! + if ( + semver.major(semver.minVersion(packageJson[type][dep])) === + semver.major(semver.minVersion(rootDeps[dep])) && + semver.gte( + semver.minVersion(packageJson[type][dep]), + semver.minVersion(rootDeps[dep]) + ) + ) { + // not a breaking change! + if (options.fix) { + packageJson[type][dep] = rootDeps[dep]; + updates.add(pathToPackageJson); + } else { + errors.push( + `Dependency \`${chalk.bold( + dep + )}\` have different version \`${chalk.bold( + packageJson[type][dep] + )}\` than the package in root \`${chalk.bold( + PACKAGE_JSON + )}\` file \`${chalk.bold(rootDeps[dep])}\`.` + ); + } + } else { + // breaking change! + if (options.breakingChanges && options.fix) { + packageJson[type][dep] = rootDeps[dep]; + updates.add(pathToPackageJson); + } else if (!options.fix) { + breakingErrors.push( + `Dependency \`${chalk.bold( + dep + )}\` have different version \`${chalk.bold( + packageJson[type][dep] + )}\` than the package in root \`${chalk.bold( + PACKAGE_JSON + )}\` file \`${chalk.bold(rootDeps[dep])}\`.` + ); + } + } + } + }); + }); + if (internalErrors.length > 0) { + errorsFound = true; + error(pathToPackageJson, internalErrors, [ + `All spartacus dependencies should be version synchronized.`, + `Version of the package in \`${chalk.bold( + 'peerDependencies' + )}\` should match package \`${chalk.bold( + 'version' + )}\` from repository.`, + `This can be automatically fixed by running \`${chalk.bold( + 'yarn config:update' + )}\`.`, + ]); + } + if (errors.length > 0) { + errorsFound = true; + error(pathToPackageJson, errors, [ + `All external dependencies should have the same version as in the root \`${chalk.bold( + PACKAGE_JSON + )}\`.`, + `This can be automatically fixed by running \`${chalk.bold( + 'yarn config:update' + )}\`.`, + ]); + } + if (breakingErrors.length > 0) { + errorsFound = true; + warning(pathToPackageJson, breakingErrors, [ + `All external dependencies should have the same version as in the root \`${chalk.bold( + PACKAGE_JSON + )}\`.`, + `Bumping to a higher dependency version is considered a breaking change!`, + `This can be automatically fixed by running \`${chalk.bold( + 'yarn config:update --breaking-changes' + )}\`.`, + ]); + } + }); + if (options.fix) { + if (updates.size > 0) { + updates.forEach((packageJsonPath) => { + logUpdatedFile(packageJsonPath); + }); + } else { + success(); + } + } else if (!errorsFound) { + success(); + } +} + +/** + * Save updated package.json files. + */ +function savePackageJsonFiles( + libraries: Record +) { + reportProgress('Saving package.json files to disk'); + Object.values(libraries).forEach((lib) => { + const pathToPackageJson = `${lib.directory}/${PACKAGE_JSON}`; + const packageJson = lib.packageJsonContent; + saveJsonFile(pathToPackageJson, packageJson); + execSync(`cd ${lib.directory} && npx sort-package-json`, { + stdio: 'ignore', + }); + logUpdatedFile(pathToPackageJson); + }); +} diff --git a/tools/config/tsconfig-paths.ts b/tools/config/tsconfig-paths.ts new file mode 100644 index 00000000000..434033981de --- /dev/null +++ b/tools/config/tsconfig-paths.ts @@ -0,0 +1,359 @@ +/** + * Purpose of this script is to check/set correctly all required paths in `compilerOptions.paths` property in all our `tsconfig` files. + * Use after adding new library or new entry point, or moving libraries in file system. + * + * This script is based on number of assumptions: + * - libraries live in `core-libs`, `feature-libs`, `integration-libs` and `projects` directories (not in subdirectories!) + * - libraries have all dependencies specified in their `package.json` + * - all dependencies to other spartacus libs are peerDependencies + * - script is run from the root project directory `ts-node ./tools/tsconfig-paths/index.ts` + * - each entry point have it's own `ng-package.json` file + * - we have `prettier:fix` script for formatting files + * - libraries have `tsconfig.lib.json` files for configuration + * - libraries have `tsconfig.schematics.json` files for schematics configuration + * - all entry points are `.ts` files + */ + +import chalk from 'chalk'; +import { assign, parse, stringify } from 'comment-json'; +import fs from 'fs'; +import glob from 'glob'; +import path from 'path'; +import { SPARTACUS_SCHEMATICS, SPARTACUS_SCOPE } from './const'; +import { + error, + Library, + logUpdatedFile, + ProgramOptions, + reportProgress, + Repository, + success, +} from './index'; + +function readTsConfigFile(path: string): any { + return parse(fs.readFileSync(path, 'utf-8')); +} + +function setCompilerOptionsPaths(tsconfigPath: string, paths: Object) { + const tsConfigContent = readTsConfigFile(tsconfigPath); + assign(tsConfigContent.compilerOptions, { paths }); + fs.writeFileSync(tsconfigPath, stringify(tsConfigContent, null, 2)); +} + +function joinPaths(...parts: string[]) { + return path.join(...parts).replace(/\\/g, '/'); +} + +interface LibraryWithSpartacusDeps extends Library { + /** + * All spartacus packages that this library depend on. + */ + spartacusDependencies: string[]; +} + +/** + * Checks and updates tsconfig files across repository. + */ +export function manageTsConfigs( + repository: Repository, + options: ProgramOptions +) { + const libraries: Record = Object.values( + repository + ) + .map((lib) => { + return { + ...lib, + spartacusDependencies: Object.keys( + lib.peerDependencies ?? {} + ).filter((dep) => dep.startsWith(`${SPARTACUS_SCOPE}/`)), + }; + }) + .reduce((acc: Record, lib) => { + acc[lib.name] = lib; + return acc; + }, {}); + + handleSchematicsConfigs(libraries, options); + handleLibConfigs(libraries, options); + handleRootConfigs(libraries, options); + handleAppConfigs(libraries, options); +} + +/** + * Compare target paths with current paths and reports extra or missing entries. + * + * @param targetPaths paths that should be in the file + * @param tsConfigPath path to targeted config + * @param silent set to tru if you don't want to output errors (eg. in fix mode) + * + * @returns true when there were some errors + */ +function comparePathsConfigs( + targetPaths: Record, + tsConfigPath: string, + silent = false +): boolean { + const tsConfig = readTsConfigFile(tsConfigPath); + const currentPaths: Record = + tsConfig?.compilerOptions?.paths ?? {}; + const errors = []; + Object.keys(currentPaths).forEach((key) => { + if (typeof targetPaths[key] === 'undefined') { + errors.push( + `Key ${chalk.bold(key)} should not be present in ${chalk.bold( + `compilerOptions.paths` + )}.` + ); + } + }); + Object.entries(targetPaths).forEach(([key, value]) => { + if (typeof currentPaths[key] === 'undefined') { + errors.push( + `Property ${chalk.bold( + `"${key}": ["${value[0]}"]` + )} is missing in ${chalk.bold('compilerOptions.paths')}.` + ); + } else if (value[0] !== currentPaths[key][0]) { + errors.push( + `Key ${chalk.bold(key)} should have value ${chalk.bold( + `[${value[0]}]` + )} in ${chalk.bold('compilerOptions.paths')}.` + ); + } + }); + if (!silent && errors.length > 0) { + error(tsConfigPath, errors, [ + `This can be automatically fixed by running \`${chalk.bold( + `yarn config:update` + )}\`.`, + ]); + } + return errors.length > 0; +} + +/** + * Compares paths and reports errors or updates configs. + * + * @returns true when there were some errors + */ +function handleConfigUpdate( + targetPaths: any, + file: string, + options: ProgramOptions +): boolean { + const errorsPresent = comparePathsConfigs(targetPaths, file, options.fix); + if (errorsPresent && options.fix) { + setCompilerOptionsPaths(file, targetPaths); + logUpdatedFile(file); + } + return errorsPresent; +} + +/** + * When library have its own schematics ts config (tsconfig.schematics.json exists) and have + * schematics as peerDependency we add path to `@spartacus/schematics` lib. + */ +function handleSchematicsConfigs( + libraries: Record, + options: ProgramOptions +): void { + const targetPaths = { + [SPARTACUS_SCHEMATICS]: ['../../projects/schematics/src/public_api'], + }; + if (options.fix) { + reportProgress('Updating tsconfig.schematics.json files'); + } else { + reportProgress('Checking tsconfig.schematics.json files'); + } + let showAllGood = true; + Object.values(libraries).forEach((library) => { + const schematicsTsConfigPaths = glob.sync( + `${library.directory}/tsconfig.schematics.json` + ); + if ( + schematicsTsConfigPaths.length && + library.spartacusDependencies.includes(SPARTACUS_SCHEMATICS) + ) { + const hadErrors = handleConfigUpdate( + targetPaths, + schematicsTsConfigPaths[0], + options + ); + if (hadErrors) { + showAllGood = false; + } + } + }); + if (showAllGood) { + success(); + } +} + +/** + * Adds paths to spartacus dependencies in `tsconfig.lib.json` files. + * We grab all spartacus dependencies and add for all of them all entry points. + */ +function handleLibConfigs( + libraries: Record, + options: ProgramOptions +): void { + if (options.fix) { + reportProgress('Updating tsconfig.lib.json files'); + } else { + reportProgress('Checking tsconfig.lib.json files'); + } + let showAllGood = true; + Object.values(libraries).forEach((library) => { + const libraryTsConfigPaths = glob.sync( + `${library.directory}/tsconfig.lib.json` + ); + if (libraryTsConfigPaths.length) { + let dependenciesEntryPoints = library.spartacusDependencies + // @spartacus/schematics library should be used only in `tsconfig.schematics.json` file. + .filter((dependency) => dependency !== SPARTACUS_SCHEMATICS) + .map((library) => libraries[library]) + .reduce((entryPoints, dependency) => { + let dependencyEntryPoints = dependency.entryPoints.reduce( + (acc, entry) => { + return { + ...acc, + // In tsconfig.lib.json files we reference built paths. eg. `@spartacus/storefront`: ['dist/storefrontlib/src/public_api'] + [entry.entryPoint]: [ + joinPaths('dist', dependency.distDir, entry.directory), + ], + }; + }, + {} + ); + return { ...entryPoints, ...dependencyEntryPoints }; + }, {}); + const hadErrors = handleConfigUpdate( + dependenciesEntryPoints, + libraryTsConfigPaths[0], + options + ); + if (hadErrors) { + showAllGood = false; + } + } + }); + if (showAllGood) { + success(); + } +} + +/** + * Add paths to all libraries and all their entry points to root `tsconfig.json` and `tsconfig.compodoc.json` files. + */ +function handleRootConfigs( + libraries: Record, + options: ProgramOptions +): void { + if (options.fix) { + reportProgress('Updating root tsconfig files'); + } else { + reportProgress('Checking root tsconfig files'); + } + let showAllGood = true; + const entryPoints = Object.values(libraries).reduce((acc, curr) => { + curr.entryPoints.forEach((entryPoint) => { + acc[entryPoint.entryPoint] = [ + // We reference source files entry points in these configs. E.g. `projects/storefrontlib/src/public_api` + joinPaths(curr.directory, entryPoint.directory, entryPoint.entryFile), + ]; + }); + return acc; + }, {}); + + const hadErrors = handleConfigUpdate(entryPoints, 'tsconfig.json', options); + const hadErrorsCompodoc = handleConfigUpdate( + entryPoints, + 'tsconfig.compodoc.json', + options + ); + if (hadErrors || hadErrorsCompodoc) { + showAllGood = false; + } + if (showAllGood) { + success(); + } +} + +function handleAppConfigs( + libraries: Record, + options: ProgramOptions +): void { + if (options.fix) { + reportProgress('Updating app tsconfig files'); + } else { + reportProgress('Checking app tsconfig files'); + } + let showAllGood = true; + // Add paths to `projects/storefrontapp/tsconfig.app.prod.json` config. + const appEntryPoints = Object.values(libraries) + .filter((lib) => lib.name !== SPARTACUS_SCHEMATICS) + .reduce((acc, curr) => { + curr.entryPoints.forEach((entryPoint) => { + acc[entryPoint.entryPoint] = [ + joinPaths('dist', curr.distDir, entryPoint.directory), + ]; + }); + return acc; + }, {}); + + const hadErrorsApp = handleConfigUpdate( + appEntryPoints, + 'projects/storefrontapp/tsconfig.app.prod.json', + options + ); + + // Add paths to `projects/storefrontapp/tsconfig.server.json` config. + const serverEntryPoints = Object.values(libraries) + .filter((lib) => lib.name !== SPARTACUS_SCHEMATICS) + .reduce((acc, curr) => { + curr.entryPoints.forEach((entryPoint) => { + // For server configuration we need relative paths that's why we append `../..` + acc[entryPoint.entryPoint] = [ + joinPaths( + '../..', + curr.directory, + entryPoint.directory, + entryPoint.entryFile + ), + ]; + }); + return acc; + }, {}); + const hadErrorsServer = handleConfigUpdate( + serverEntryPoints, + 'projects/storefrontapp/tsconfig.server.json', + options + ); + + // Add paths to `projects/storefrontapp/tsconfig.server.prod.json` config. + const serverProdEntryPoints = Object.values(libraries) + .filter((lib) => lib.name !== SPARTACUS_SCHEMATICS) + .reduce((acc, curr) => { + curr.entryPoints.forEach((entryPoint) => { + // For server configuration we need relative paths that's why we append `../..` + acc[entryPoint.entryPoint] = [ + joinPaths('../..', 'dist', curr.distDir, entryPoint.directory), + ]; + }); + return acc; + }, {}); + + const hadErrorsServerProd = handleConfigUpdate( + serverProdEntryPoints, + 'projects/storefrontapp/tsconfig.server.prod.json', + options + ); + + if (hadErrorsApp || hadErrorsServer || hadErrorsServerProd) { + showAllGood = false; + } + if (showAllGood) { + success(); + } +} diff --git a/tools/tsconfig-paths/index.ts b/tools/tsconfig-paths/index.ts deleted file mode 100644 index 721404c5119..00000000000 --- a/tools/tsconfig-paths/index.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Purpose of this script is to set correctly all required paths in `compilerOptions.paths` property in all our `tsconfig` files. - * Use after adding new library or new entry point, or moving libraries in file system. - * - * This script is based on number of assumptions: - * - libraries live in `core-libs`, `feature-libs`, `integration-libs` and `projects` directories (not in subdirectories!) - * - libraries have all dependencies specified in their `package.json` - * - all dependencies to other spartacus libs are peerDependencies - * - script is run from the root project directory `ts-node ./tools/tsconfig-paths/index.ts` - * - each entry point have it's own `ng-package.json` file - * - we have `prettier:fix` script for formatting files - * - libraries have `tsconfig.lib.json` files for configuration - * - libraries have `tsconfig.schematics.json` files for schematics configuration - * - all entry points are `.ts` files - */ - -import { execSync } from 'child_process'; -import { assign, parse, stringify } from 'comment-json'; -import fs from 'fs'; -import glob from 'glob'; -import path from 'path'; - -/** - * Paths to `package.json` files for all libraries. - */ -const librariesPaths = glob.sync( - '{core-libs,feature-libs,integration-libs,projects}/!(node_modules)/package.json', - { - ignore: [ - 'projects/storefrontapp-e2e-cypress/package.json', - 'projects/dev-schematics/package.json', - 'projects/storefrontstyles/package.json', - 'projects/schematics/package.json', // excluded as it is treated differently than feature libraries - ], - } -); - -// Utility functions -function readJsonFile(path: string) { - return JSON.parse(fs.readFileSync(path, 'utf-8')); -} - -function setCompilerOptionsPaths(tsconfigPath: string, paths: Object) { - const tsConfigContent = parse(fs.readFileSync(tsconfigPath, 'utf-8')); - if (Object.keys(paths).length) { - assign(tsConfigContent.compilerOptions, { paths }); - fs.writeFileSync(tsconfigPath, stringify(tsConfigContent, null, 2)); - } -} - -function joinPaths(...parts: string[]) { - return path.join(...parts).replace(/\\/g, '/'); -} - -function logUpdatedFile(path: string) { - console.log(`✅ Updated ${path}`); -} - -console.log('\nAnalyzing project structure...'); -const libraries: { - [library: string]: { - /** - * Name of the library. - * eg. `@spartacus/core` - */ - name: string; - /** - * Directory to which the builded lib lands in `dist` directory. - * eg. for `@spartacus/storefront` -> `storefrontlib` - */ - distDir: string; - /** - * All entry points for library (including the main entry point). - */ - entryPoints: Array<{ - /** - * Reference to entry point. - * eg. `@spartacus/organization/administration` - */ - entryPoint: string; - /** - * Directory of entry point. - * eg. `/administration` - */ - path: string; - /** - * Path to entry file for entry point (inside directory from property `path`). - * eg. `src/public_api` (without .ts extension) - */ - entryFile: string; - }>; - /** - * All spartacus packages that this library depend on. - */ - spartacusDependencies: string[]; - /** - * Directory where package lives. - * eg. for `@spartacus/core` -> `projects/core` - */ - path: string; - }; -} = librariesPaths - .map((libPath) => { - const packageJson = readJsonFile(libPath); - const peerDependencies = Object.keys(packageJson.peerDependencies ?? {}); - const libDir = libPath.substring( - 0, - libPath.length - `/package.json`.length - ); - - const ngPackageFilesPaths = glob.sync(`${libDir}/**/ng-package.json`); - const entryPoints = ngPackageFilesPaths.map((ngPackagePath) => { - const ngPackageFileContent = readJsonFile(ngPackagePath); - let pathWithoutLibDirectory = ngPackagePath.substring(libDir.length); - let pathWithoutNgPackage = pathWithoutLibDirectory.substring( - 0, - pathWithoutLibDirectory.length - `/ng-package.json`.length - ); - return { - entryPoint: `${packageJson.name}${pathWithoutNgPackage}`, // eg. `@spartacus/organization/administration` - path: `${pathWithoutNgPackage}`, // eg. `/administration` - entryFile: `${ngPackageFileContent.lib.entryFile.replace('.ts', '')}`, // eg. `src/public_api` (without .ts extension) - }; - }); - - return { - name: packageJson.name, - distDir: libDir.split('/')[1], - spartacusDependencies: peerDependencies.filter((dep) => - dep.startsWith('@spartacus/') - ), - path: libDir, - entryPoints, - }; - }) - .reduce((acc, lib) => { - acc[lib.name] = lib; - return acc; - }, {}); - -/** - * When library have it's own schematics (tsconfig.schematics.json exists) and have - * schematics as peerDependency we add path to `@spartacus/schematics` lib. - */ -console.log('\nUpdating tsconfig.schematics.json files\n'); -Object.values(libraries).forEach((library) => { - const schematicsTsConfigPaths = glob.sync( - `${library.path}/tsconfig.schematics.json` - ); - if ( - schematicsTsConfigPaths.length && - library.spartacusDependencies.includes('@spartacus/schematics') - ) { - setCompilerOptionsPaths(schematicsTsConfigPaths[0], { - '@spartacus/schematics': ['../../projects/schematics/src/public_api'], - }); - } - if (schematicsTsConfigPaths.length) { - logUpdatedFile(schematicsTsConfigPaths[0]); - } -}); - -/** - * Adds paths to spartacus dependencies in libraries `tsconfig.lib.json` files. - * We grab all spartacus dependencies and add for all of them all entry points. - */ -console.log('\nUpdating tsconfig.lib.json files\n'); -Object.values(libraries).forEach((library) => { - const libraryTsConfigPaths = glob.sync(`${library.path}/tsconfig.lib.json`); - if (libraryTsConfigPaths.length) { - let dependenciesEntryPoints = library.spartacusDependencies - // @spartacus/schematics library should be used only in `tsconfig.schematics.json` file. - .filter((dependency) => dependency !== '@spartacus/schematics') - .map((library) => libraries[library]) - .reduce((entryPoints, dependency) => { - let dependencyEntryPoints = dependency.entryPoints.reduce( - (acc, entry) => { - return { - ...acc, - // In tsconfig.lib.json files we reference builded paths. eg. `@spartacus/storefront`: ['dist/storefrontlib/src/public_api']` - [entry.entryPoint]: [ - joinPaths('dist', dependency.distDir, entry.path), - ], - }; - }, - {} - ); - return { ...entryPoints, ...dependencyEntryPoints }; - }, {}); - setCompilerOptionsPaths(libraryTsConfigPaths[0], dependenciesEntryPoints); - logUpdatedFile(libraryTsConfigPaths[0]); - } -}); - -/** - * Add paths to all libraries and all their entry points to root `tsconfig.json` and `tsconfig.compodoc.json` files. - */ -console.log('\nUpdating base tsconfig files\n'); -const entryPoints = Object.values(libraries).reduce( - (acc, curr) => { - curr.entryPoints.forEach((entryPoint) => { - acc[entryPoint.entryPoint] = [ - // We reference source files entry points in these configs. eg. `projects/storefrontlib/src/public_api` - joinPaths(curr.path, entryPoint.path, entryPoint.entryFile), - ]; - }); - return acc; - }, - { - // Add schematics library by hand, as we don't traverse this library. - '@spartacus/schematics': ['projects/schematics/src/public_api'], - } -); - -setCompilerOptionsPaths('./tsconfig.json', entryPoints); -logUpdatedFile('tsconfig.json'); -setCompilerOptionsPaths('./tsconfig.compodoc.json', entryPoints); -logUpdatedFile('tsconfig.compodoc.json'); - -console.log('\nUpdating storefrontapp configuration\n'); -/** - * Add paths to `projects/storefrontapp/tsconfig.app.prod.json` config. - */ -const appEntryPoints = Object.values(libraries).reduce((acc, curr) => { - curr.entryPoints.forEach((entryPoint) => { - acc[entryPoint.entryPoint] = [ - joinPaths('dist', curr.distDir, entryPoint.path), - ]; - }); - return acc; -}, {}); - -setCompilerOptionsPaths( - 'projects/storefrontapp/tsconfig.app.prod.json', - appEntryPoints -); -logUpdatedFile('projects/storefrontapp/tsconfig.app.prod.json'); - -/** - * Add paths to `projects/storefrontapp/tsconfig.server.json` config. - */ -const serverEntryPoints = Object.values(libraries).reduce((acc, curr) => { - curr.entryPoints.forEach((entryPoint) => { - // For server configuration we need relative paths that's why we append `../..` - acc[entryPoint.entryPoint] = [ - joinPaths('../..', curr.path, entryPoint.path, entryPoint.entryFile), - ]; - }); - return acc; -}, {}); - -setCompilerOptionsPaths( - 'projects/storefrontapp/tsconfig.server.json', - serverEntryPoints -); -logUpdatedFile('projects/storefrontapp/tsconfig.server.json'); - -/** - * Add paths to `projects/storefrontapp/tsconfig.server.prod.json` config. - */ -const serverProdEntryPoints = Object.values(libraries).reduce((acc, curr) => { - curr.entryPoints.forEach((entryPoint) => { - // For server configuration we need relative paths that's why we append `../..` - acc[entryPoint.entryPoint] = [ - joinPaths('../..', 'dist', curr.distDir, entryPoint.path), - ]; - }); - return acc; -}, {}); - -setCompilerOptionsPaths( - 'projects/storefrontapp/tsconfig.server.prod.json', - serverProdEntryPoints -); -logUpdatedFile('projects/storefrontapp/tsconfig.server.prod.json'); - -/** - * Format all files. - */ -console.log('\nFormatting files (might take some time)...\n'); -execSync('yarn prettier:fix'); -console.log('✨ Update completed'); diff --git a/yarn.lock b/yarn.lock index 8d996846dad..0c6d7c47dbe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9707,6 +9707,11 @@ nan@^2.12.1: resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== +nanoid@^3.1.20: + version "3.1.20" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788" + integrity sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -11120,6 +11125,13 @@ postcss-scss@^2.1.1: dependencies: postcss "^7.0.6" +postcss-scss@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/postcss-scss/-/postcss-scss-3.0.4.tgz#74ce30ec9de615b41aeab1249e7557698e372e73" + integrity sha512-BAkBZ35aXhCeBRmliHylYqTN1PvNJyh9aBPQHUmk9SdDdbk7n3GExm7cQivDckOgJpB+agyig9TwRAmf6WnvfA== + dependencies: + postcss "^8.1.6" + postcss-selector-parser@^3.0.0: version "3.1.2" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-3.1.2.tgz#b310f5c4c0fdaf76f94902bbaa30db6aa84f5270" @@ -11210,6 +11222,15 @@ postcss@^7.0.21, postcss@^7.0.26: source-map "^0.6.1" supports-color "^6.1.0" +postcss@^8.1.6, postcss@^8.2.3: + version "8.2.3" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.3.tgz#14ed1294850c99661761d9cb68863718eb75690d" + integrity sha512-tdmNCCmxJEsLZNj810qlj8QbvnUNKFL9A5doV+uHrGGK/YNKWEslrytnHDWr9M/GgGjfUFwXCRbxd/b6IoRBXQ== + dependencies: + colorette "^1.2.1" + nanoid "^3.1.20" + source-map "^0.6.1" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" From f97fb78adbe92a7babdb3b97569764095c90c154 Mon Sep 17 00:00:00 2001 From: Parthlakhani Date: Mon, 25 Jan 2021 16:46:13 -0500 Subject: [PATCH 22/30] GH-8615 Fix: Sort unit list in forms (#10575) * GH-8615 sort unit list in forms * GH-8615: add unit test * refactor: add and update unit tests * refactor: Add empty lines --- .../core/services/org-unit.service.spec.ts | 48 +++++++++++++++++++ .../core/services/org-unit.service.ts | 11 ++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/feature-libs/organization/administration/core/services/org-unit.service.spec.ts b/feature-libs/organization/administration/core/services/org-unit.service.spec.ts index 1899f3acee3..c74c7923a70 100644 --- a/feature-libs/organization/administration/core/services/org-unit.service.spec.ts +++ b/feature-libs/organization/administration/core/services/org-unit.service.spec.ts @@ -576,6 +576,54 @@ describe('OrgUnitService', () => { new OrgUnitActions.LoadOrgUnitNodes({ userId }) ); }); + + it('should filter unit list', () => { + store.dispatch( + new OrgUnitActions.LoadOrgUnitNodesSuccess([ + { id: 'unit1', active: true } as B2BUnitNode, + { id: 'unit2', active: false } as B2BUnitNode, + { id: 'unit3', active: true } as B2BUnitNode, + { id: 'unit4', active: false } as B2BUnitNode, + ]) + ); + let unitNodes: B2BUnitNode[]; + service + .getActiveUnitList() + .subscribe((data) => { + unitNodes = data; + }) + .unsubscribe(); + + expect(unitNodes).toEqual([ + { id: 'unit1', active: true } as B2BUnitNode, + { id: 'unit3', active: true } as B2BUnitNode, + ]); + }); + + it('should sort unit list', () => { + store.dispatch( + new OrgUnitActions.LoadOrgUnitNodesSuccess([ + { id: 'Bunit', active: true } as B2BUnitNode, + { id: 'Cunit', active: true } as B2BUnitNode, + { id: 'Aunit', active: true } as B2BUnitNode, + { id: 'Dunit', active: true } as B2BUnitNode, + ]) + ); + let unitNodes: B2BUnitNode[]; + service + .getActiveUnitList() + .subscribe((data) => { + unitNodes = data; + }) + .unsubscribe(); + + expect(unitNodes).toEqual([ + { id: 'Aunit', active: true } as B2BUnitNode, + { id: 'Bunit', active: true } as B2BUnitNode, + { id: 'Cunit', active: true } as B2BUnitNode, + { id: 'Dunit', active: true } as B2BUnitNode, + ]); + }); }); describe('get tree', () => { diff --git a/feature-libs/organization/administration/core/services/org-unit.service.ts b/feature-libs/organization/administration/core/services/org-unit.service.ts index e57df180671..50f59ab7006 100644 --- a/feature-libs/organization/administration/core/services/org-unit.service.ts +++ b/feature-libs/organization/administration/core/services/org-unit.service.ts @@ -249,10 +249,19 @@ export class OrgUnitService { getActiveUnitList(): Observable { return this.getList().pipe( - map((units) => units.filter((unit) => unit.active)) + map((units) => units.filter((unit) => unit.active)), + map((units) => units.sort(this.sortUnitList)) ); } + protected sortUnitList(a: B2BUnitNode, b: B2BUnitNode) { + return a.id.toLowerCase() < b.id.toLowerCase() + ? -1 + : a.id.toLowerCase() > b.id.toLowerCase() + ? 1 + : 0; + } + getUsers( orgUnitId: string, roleId: string, From b3fdeb698f13667bd095888398f010822896a1c2 Mon Sep 17 00:00:00 2001 From: Stan Date: Tue, 26 Jan 2021 15:44:57 +0100 Subject: [PATCH 23/30] Refactor base modules and recipies to facilitiate moving feature to separate libraries (#10644) In order to make our transition to feature-driven architecture in 3.x, we need to decouple and change the base core and UI modules of Spartacus. Closes #10635 --- projects/core/public_api.ts | 1 + projects/core/src/base-core.module.ts | 37 +++ .../features-config/features-config.module.ts | 2 +- projects/core/src/occ/base-occ.module.ts | 34 +++ .../src/occ/config/meta-tag-config.module.ts | 12 + projects/core/src/occ/index.ts | 2 + projects/core/src/occ/occ.module.ts | 3 + .../src/app/app-routing.module.ts | 13 ++ projects/storefrontapp/src/app/app.module.ts | 37 +-- .../features/administration-feature.module.ts | 30 +++ .../spartacus/features/cdc-feature.module.ts} | 8 +- .../spartacus/features/cds-feature.module.ts} | 7 +- .../features/order-approval-feature.module.ts | 29 +++ .../features/qualtrics-feature.module.ts | 18 ++ .../features/storefinder-feature.module.ts | 26 +++ .../spartacus-b2b-configuration.module.ts | 34 +++ .../spartacus-b2c-configuration.module.ts | 37 +++ .../spartacus/spartacus-features.module.ts | 212 ++++++++++++++++++ .../src/app/spartacus/spartacus.module.ts | 22 ++ .../src/environments/b2b/b2b.feature.ts | 55 ----- .../src/environments/b2c/b2c.feature.ts | 47 ---- .../src/base-storefront.module.ts | 38 ++++ .../src/cms-components/cms-lib.module.ts | 3 + .../container/product-list.component.ts | 2 +- .../product-scroll.component.ts | 4 +- .../storefrontlib/src/events/events.module.ts | 3 + .../src/layout/main/main.module.ts | 3 + projects/storefrontlib/src/public_api.ts | 1 + .../recipes/storefront-foundation.module.ts | 13 +- .../src/recipes/storefront.module.ts | 3 + .../src/shared/config/view-config.module.ts | 5 + 31 files changed, 600 insertions(+), 141 deletions(-) create mode 100644 projects/core/src/base-core.module.ts create mode 100644 projects/core/src/occ/base-occ.module.ts create mode 100644 projects/core/src/occ/config/meta-tag-config.module.ts create mode 100644 projects/storefrontapp/src/app/app-routing.module.ts create mode 100644 projects/storefrontapp/src/app/spartacus/features/administration-feature.module.ts rename projects/storefrontapp/src/{environments/cdc/cdc.feature.ts => app/spartacus/features/cdc-feature.module.ts} (76%) rename projects/storefrontapp/src/{environments/cds/cds.feature.ts => app/spartacus/features/cds-feature.module.ts} (86%) create mode 100644 projects/storefrontapp/src/app/spartacus/features/order-approval-feature.module.ts create mode 100644 projects/storefrontapp/src/app/spartacus/features/qualtrics-feature.module.ts create mode 100644 projects/storefrontapp/src/app/spartacus/features/storefinder-feature.module.ts create mode 100644 projects/storefrontapp/src/app/spartacus/spartacus-b2b-configuration.module.ts create mode 100644 projects/storefrontapp/src/app/spartacus/spartacus-b2c-configuration.module.ts create mode 100644 projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts create mode 100644 projects/storefrontapp/src/app/spartacus/spartacus.module.ts delete mode 100644 projects/storefrontapp/src/environments/b2b/b2b.feature.ts delete mode 100644 projects/storefrontapp/src/environments/b2c/b2c.feature.ts create mode 100644 projects/storefrontlib/src/base-storefront.module.ts diff --git a/projects/core/public_api.ts b/projects/core/public_api.ts index a4be72dd872..d8fbd714c1e 100644 --- a/projects/core/public_api.ts +++ b/projects/core/public_api.ts @@ -26,6 +26,7 @@ export * from './src/user/index'; export * from './src/util/index'; export * from './src/window/index'; export * from './src/lazy-loading/index'; +export * from './src/base-core.module'; /** AUGMENTABLE_TYPES_START */ export { Product, Price, Stock } from './src/model/product.model'; diff --git a/projects/core/src/base-core.module.ts b/projects/core/src/base-core.module.ts new file mode 100644 index 00000000000..4441eba0071 --- /dev/null +++ b/projects/core/src/base-core.module.ts @@ -0,0 +1,37 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { StateModule } from './state/state.module'; +import { ConfigInitializerModule } from './config/config-initializer/config-initializer.module'; +import { ConfigModule } from './config/config.module'; +import { ConfigValidatorModule } from './config/config-validator/config-validator.module'; +import { I18nModule } from './i18n/i18n.module'; +import { CmsModule } from './cms/cms.module'; +import { GlobalMessageModule } from './global-message/global-message.module'; +import { ProcessModule } from './process/process.module'; +import { FeaturesConfigModule } from './features-config/features-config.module'; +import { SiteContextModule } from './site-context/site-context.module'; +import { MetaTagConfigModule } from './occ/config/meta-tag-config.module'; +import { BaseOccModule } from './occ/base-occ.module'; + +@NgModule({ + imports: [ + StateModule.forRoot(), + ConfigModule.forRoot(), + ConfigInitializerModule.forRoot(), + ConfigValidatorModule.forRoot(), + I18nModule.forRoot(), + CmsModule.forRoot(), + GlobalMessageModule.forRoot(), + ProcessModule.forRoot(), + FeaturesConfigModule.forRoot(), + SiteContextModule.forRoot(), // should be imported after RouterModule.forRoot, because it overwrites UrlSerializer + MetaTagConfigModule.forRoot(), + BaseOccModule.forRoot(), + ], +}) +export class BaseCoreModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: BaseCoreModule, + }; + } +} diff --git a/projects/core/src/features-config/features-config.module.ts b/projects/core/src/features-config/features-config.module.ts index 148c0fe9272..046dee5bed7 100644 --- a/projects/core/src/features-config/features-config.module.ts +++ b/projects/core/src/features-config/features-config.module.ts @@ -10,7 +10,7 @@ import { provideDefaultConfig } from '../config/config-providers'; }) export class FeaturesConfigModule { static forRoot( - defaultLevel?: string + defaultLevel = '3.0' ): ModuleWithProviders { return { ngModule: FeaturesConfigModule, diff --git a/projects/core/src/occ/base-occ.module.ts b/projects/core/src/occ/base-occ.module.ts new file mode 100644 index 00000000000..1ea9e2d3d72 --- /dev/null +++ b/projects/core/src/occ/base-occ.module.ts @@ -0,0 +1,34 @@ +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { provideConfigValidator } from '../config/config-validator/config-validator'; +import { OccConfigLoaderModule } from './config-loader/occ-config-loader.module'; +import { defaultOccConfig } from './config/default-occ-config'; +import { occConfigValidator } from './config/occ-config-validator'; +import { WithCredentialsInterceptor } from './interceptors/with-credentials.interceptor'; +import { CmsOccModule } from './adapters/cms/cms-occ.module'; +import { SiteContextOccModule } from './adapters/site-context/site-context-occ.module'; +import { provideDefaultConfig } from '../config/config-providers'; + +@NgModule({ + imports: [ + CmsOccModule, + SiteContextOccModule, + OccConfigLoaderModule.forRoot(), + ], +}) +export class BaseOccModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: BaseOccModule, + providers: [ + { + provide: HTTP_INTERCEPTORS, + useExisting: WithCredentialsInterceptor, + multi: true, + }, + provideDefaultConfig(defaultOccConfig), + provideConfigValidator(occConfigValidator), + ], + }; + } +} diff --git a/projects/core/src/occ/config/meta-tag-config.module.ts b/projects/core/src/occ/config/meta-tag-config.module.ts new file mode 100644 index 00000000000..2cf6252474e --- /dev/null +++ b/projects/core/src/occ/config/meta-tag-config.module.ts @@ -0,0 +1,12 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { provideConfigFromMetaTags } from './config-from-meta-tag-factory'; + +@NgModule({}) +export class MetaTagConfigModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: MetaTagConfigModule, + providers: [...provideConfigFromMetaTags()], + }; + } +} diff --git a/projects/core/src/occ/index.ts b/projects/core/src/occ/index.ts index 038e7813d3c..54a343cc54b 100644 --- a/projects/core/src/occ/index.ts +++ b/projects/core/src/occ/index.ts @@ -4,8 +4,10 @@ export * from './config/config-from-meta-tag-factory'; export * from './config/default-occ-config'; export * from './config/occ-config'; export * from './config/occ-config-validator'; +export * from './config/meta-tag-config.module'; export * from './interceptors/index'; export * from './occ-models/index'; export * from './occ.module'; +export * from './base-occ.module'; export * from './services/index'; export * from './utils/index'; diff --git a/projects/core/src/occ/occ.module.ts b/projects/core/src/occ/occ.module.ts index 169963fea0d..803b5802155 100644 --- a/projects/core/src/occ/occ.module.ts +++ b/projects/core/src/occ/occ.module.ts @@ -15,6 +15,9 @@ import { UserOccModule } from './adapters/user/user-occ.module'; import { provideDefaultConfig } from '../config/config-providers'; import { CostCenterOccModule } from './adapters/cost-center/cost-center-occ.module'; +/** + * @deprecated since 3.1, use individual imports instead + */ @NgModule({ imports: [ AsmOccModule, diff --git a/projects/storefrontapp/src/app/app-routing.module.ts b/projects/storefrontapp/src/app/app-routing.module.ts new file mode 100644 index 00000000000..113323c6821 --- /dev/null +++ b/projects/storefrontapp/src/app/app-routing.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +@NgModule({ + imports: [ + RouterModule.forRoot([], { + anchorScrolling: 'enabled', + relativeLinkResolution: 'corrected', + initialNavigation: 'enabled', + }), + ], +}) +export class AppRoutingModule {} diff --git a/projects/storefrontapp/src/app/app.module.ts b/projects/storefrontapp/src/app/app.module.ts index 3e24b7a5949..772f78077d4 100644 --- a/projects/storefrontapp/src/app/app.module.ts +++ b/projects/storefrontapp/src/app/app.module.ts @@ -10,16 +10,14 @@ import { import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { translationChunksConfig, translations } from '@spartacus/assets'; import { ConfigModule, TestConfigModule } from '@spartacus/core'; -import { - JsonLdBuilderModule, - StorefrontComponent, -} from '@spartacus/storefront'; -import { b2bFeature } from '../environments/b2b/b2b.feature'; -import { b2cFeature } from '../environments/b2c/b2c.feature'; -import { cdcFeature } from '../environments/cdc/cdc.feature'; -import { cdsFeature } from '../environments/cds/cds.feature'; +import { StorefrontComponent } from '@spartacus/storefront'; import { environment } from '../environments/environment'; import { TestOutletModule } from '../test-outlets/test-outlet.module'; +import { HttpClientModule } from '@angular/common/http'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; +import { AppRoutingModule } from './app-routing.module'; +import { SpartacusModule } from './spartacus/spartacus.module'; registerLocaleData(localeDe); registerLocaleData(localeJa); @@ -30,27 +28,15 @@ if (!environment.production) { devImports.push(StoreDevtoolsModule.instrument()); } -let additionalImports = []; - -if (environment.cds) { - additionalImports = [...additionalImports, ...cdsFeature.imports]; -} - -if (environment.b2b) { - additionalImports = [...additionalImports, ...b2bFeature.imports]; -} else { - additionalImports = [...additionalImports, ...b2cFeature.imports]; -} - -if (environment.cdc) { - additionalImports = [...additionalImports, ...cdcFeature.imports]; -} - @NgModule({ imports: [ BrowserModule.withServerTransition({ appId: 'spartacus-app' }), BrowserTransferStateModule, - JsonLdBuilderModule, + HttpClientModule, + AppRoutingModule, + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + SpartacusModule, ConfigModule.withConfig({ backend: { occ: { @@ -79,7 +65,6 @@ if (environment.cdc) { level: '2.1', }, }), - ...additionalImports, TestOutletModule, // custom usages of cxOutletRef only for e2e testing TestConfigModule.forRoot({ cookie: 'cxConfigE2E' }), // Injects config dynamically from e2e tests. Should be imported after other config modules. diff --git a/projects/storefrontapp/src/app/spartacus/features/administration-feature.module.ts b/projects/storefrontapp/src/app/spartacus/features/administration-feature.module.ts new file mode 100644 index 00000000000..d039b2f7fd8 --- /dev/null +++ b/projects/storefrontapp/src/app/spartacus/features/administration-feature.module.ts @@ -0,0 +1,30 @@ +import { NgModule } from '@angular/core'; +import { AdministrationRootModule } from '@spartacus/organization/administration/root'; +import { provideConfig } from '@spartacus/core'; +import { + organizationTranslationChunksConfig, + organizationTranslations, +} from '@spartacus/organization/administration/assets'; + +@NgModule({ + declarations: [], + imports: [AdministrationRootModule], + providers: [ + provideConfig({ + featureModules: { + organizationAdministration: { + module: () => + import('@spartacus/organization/administration').then( + (m) => m.AdministrationModule + ), + }, + }, + i18n: { + resources: organizationTranslations, + chunks: organizationTranslationChunksConfig, + fallbackLang: 'en', + }, + }), + ], +}) +export class AdministrationFeatureModule {} diff --git a/projects/storefrontapp/src/environments/cdc/cdc.feature.ts b/projects/storefrontapp/src/app/spartacus/features/cdc-feature.module.ts similarity index 76% rename from projects/storefrontapp/src/environments/cdc/cdc.feature.ts rename to projects/storefrontapp/src/app/spartacus/features/cdc-feature.module.ts index 119ec81aa85..6a34fc3dff1 100644 --- a/projects/storefrontapp/src/environments/cdc/cdc.feature.ts +++ b/projects/storefrontapp/src/app/spartacus/features/cdc-feature.module.ts @@ -1,6 +1,7 @@ +import { NgModule } from '@angular/core'; import { CdcModule } from '@spartacus/cdc'; -import { FeatureEnvironment } from '../models/feature.model'; -export const cdcFeature: FeatureEnvironment = { + +@NgModule({ imports: [ CdcModule.forRoot({ cdc: [ @@ -17,4 +18,5 @@ export const cdcFeature: FeatureEnvironment = { ], }), ], -}; +}) +export class CdcFeatureModule {} diff --git a/projects/storefrontapp/src/environments/cds/cds.feature.ts b/projects/storefrontapp/src/app/spartacus/features/cds-feature.module.ts similarity index 86% rename from projects/storefrontapp/src/environments/cds/cds.feature.ts rename to projects/storefrontapp/src/app/spartacus/features/cds-feature.module.ts index d96da020be3..ec503cf40cb 100644 --- a/projects/storefrontapp/src/environments/cds/cds.feature.ts +++ b/projects/storefrontapp/src/app/spartacus/features/cds-feature.module.ts @@ -1,7 +1,7 @@ +import { NgModule } from '@angular/core'; import { CdsModule } from '@spartacus/cds'; -import { FeatureEnvironment } from '../models/feature.model'; -export const cdsFeature: FeatureEnvironment = { +@NgModule({ imports: [ CdsModule.forRoot({ cds: { @@ -24,4 +24,5 @@ export const cdsFeature: FeatureEnvironment = { }, }), ], -}; +}) +export class CdsFeatureModule {} diff --git a/projects/storefrontapp/src/app/spartacus/features/order-approval-feature.module.ts b/projects/storefrontapp/src/app/spartacus/features/order-approval-feature.module.ts new file mode 100644 index 00000000000..a990ffd0d45 --- /dev/null +++ b/projects/storefrontapp/src/app/spartacus/features/order-approval-feature.module.ts @@ -0,0 +1,29 @@ +import { NgModule } from '@angular/core'; +import { OrderApprovalRootModule } from '@spartacus/organization/order-approval/root'; +import { provideConfig } from '@spartacus/core'; +import { + orderApprovalTranslationChunksConfig, + orderApprovalTranslations, +} from '@spartacus/organization/order-approval/assets'; + +@NgModule({ + imports: [OrderApprovalRootModule], + providers: [ + provideConfig({ + featureModules: { + organizationOrderApproval: { + module: () => + import('@spartacus/organization/order-approval').then( + (m) => m.OrderApprovalModule + ), + }, + }, + i18n: { + resources: orderApprovalTranslations, + chunks: orderApprovalTranslationChunksConfig, + fallbackLang: 'en', + }, + }), + ], +}) +export class OrderApprovalFeatureModule {} diff --git a/projects/storefrontapp/src/app/spartacus/features/qualtrics-feature.module.ts b/projects/storefrontapp/src/app/spartacus/features/qualtrics-feature.module.ts new file mode 100644 index 00000000000..d1e484c3cfb --- /dev/null +++ b/projects/storefrontapp/src/app/spartacus/features/qualtrics-feature.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { QualtricsRootModule } from '@spartacus/qualtrics/root'; +import { provideConfig } from '@spartacus/core'; + +@NgModule({ + imports: [QualtricsRootModule], + providers: [ + provideConfig({ + featureModules: { + qualtrics: { + module: () => + import('@spartacus/qualtrics').then((m) => m.QualtricsModule), + }, + }, + }), + ], +}) +export class QualtricsFeatureModule {} diff --git a/projects/storefrontapp/src/app/spartacus/features/storefinder-feature.module.ts b/projects/storefrontapp/src/app/spartacus/features/storefinder-feature.module.ts new file mode 100644 index 00000000000..4957f613a3a --- /dev/null +++ b/projects/storefrontapp/src/app/spartacus/features/storefinder-feature.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { StoreFinderRootModule } from '@spartacus/storefinder/root'; +import { provideConfig } from '@spartacus/core'; +import { + storeFinderTranslationChunksConfig, + storeFinderTranslations, +} from '@spartacus/storefinder/assets'; + +@NgModule({ + imports: [StoreFinderRootModule], + providers: [ + provideConfig({ + featureModules: { + storeFinder: { + module: () => + import('@spartacus/storefinder').then((m) => m.StoreFinderModule), + }, + }, + i18n: { + resources: storeFinderTranslations, + chunks: storeFinderTranslationChunksConfig, + }, + }), + ], +}) +export class StorefinderFeatureModule {} diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-b2b-configuration.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-b2b-configuration.module.ts new file mode 100644 index 00000000000..a12eece020d --- /dev/null +++ b/projects/storefrontapp/src/app/spartacus/spartacus-b2b-configuration.module.ts @@ -0,0 +1,34 @@ +import { NgModule } from '@angular/core'; +import { provideConfig } from '@spartacus/core'; +import { + defaultCmsContentProviders, + layoutConfig, + mediaConfig, +} from '@spartacus/storefront'; +import { + defaultB2bCheckoutConfig, + defaultB2bOccConfig, +} from '@spartacus/setup'; + +@NgModule({ + providers: [ + // b2c + provideConfig(layoutConfig), + provideConfig(mediaConfig), + ...defaultCmsContentProviders, + // b2b + provideConfig(defaultB2bOccConfig), + provideConfig(defaultB2bCheckoutConfig), + provideConfig({ + context: { + urlParameters: ['baseSite', 'language', 'currency'], + baseSite: ['powertools-spa'], + }, + pwa: { + enabled: true, + addToHomeScreen: true, + }, + }), + ], +}) +export class SpartacusB2bConfigurationModule {} diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-b2c-configuration.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-b2c-configuration.module.ts new file mode 100644 index 00000000000..cc311c4187d --- /dev/null +++ b/projects/storefrontapp/src/app/spartacus/spartacus-b2c-configuration.module.ts @@ -0,0 +1,37 @@ +import { NgModule } from '@angular/core'; +import { provideConfig } from '@spartacus/core'; +import { + defaultCmsContentProviders, + layoutConfig, + mediaConfig, +} from '@spartacus/storefront'; + +@NgModule({ + providers: [ + provideConfig(layoutConfig), + provideConfig(mediaConfig), + ...defaultCmsContentProviders, + provideConfig({ + context: { + urlParameters: ['baseSite', 'language', 'currency'], + baseSite: [ + 'electronics-spa', + 'electronics', + 'apparel-de', + 'apparel-uk', + 'apparel-uk-spa', + ], + }, + pwa: { + enabled: true, + addToHomeScreen: true, + }, + cart: { + selectiveCart: { + enabled: true, + }, + }, + }), + ], +}) +export class SpartacusB2cConfigurationModule {} diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts new file mode 100644 index 00000000000..3d33ee2995c --- /dev/null +++ b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts @@ -0,0 +1,212 @@ +import { NgModule } from '@angular/core'; +import { + AddressBookModule, + AnonymousConsentManagementBannerModule, + AnonymousConsentsDialogModule, + AsmModule, + BannerCarouselModule, + BannerModule, + BreadcrumbModule, + CartComponentModule, + CartPageEventModule, + CategoryNavigationModule, + CheckoutComponentModule, + CloseAccountModule, + CmsParagraphModule, + ConsentManagementModule, + FooterNavigationModule, + ForgotPasswordModule, + HamburgerMenuModule, + JsonLdBuilderModule, + LinkModule, + MyCouponsModule, + MyInterestsModule, + NavigationModule, + NotificationPreferenceModule, + OrderCancellationModule, + OrderConfirmationModule, + OrderDetailsModule, + OrderHistoryModule, + OrderReturnModule, + PageEventModule, + PaymentMethodsModule, + ProductCarouselModule, + ProductDetailsPageModule, + ProductFacetNavigationModule, + ProductImagesModule, + ProductIntroModule, + ProductListingPageModule, + ProductListModule, + ProductPageEventModule, + ProductReferencesModule, + ProductSummaryModule, + ProductTabsModule, + ProductVariantsModule, + ReplenishmentOrderConfirmationModule, + ReplenishmentOrderDetailsModule, + ReplenishmentOrderHistoryModule, + ResetPasswordModule, + ReturnRequestDetailModule, + ReturnRequestListModule, + SearchBoxModule, + SiteContextSelectorModule, + StockNotificationModule, + TabParagraphContainerModule, + UpdateEmailModule, + UpdatePasswordModule, + UpdateProfileModule, + UserComponentModule, + WishListModule, +} from '@spartacus/storefront'; +import { + AnonymousConsentsModule, + AsmOccModule, + AuthModule, + CartModule, + CartOccModule, + CheckoutModule, + CheckoutOccModule, + CostCenterOccModule, + ExternalRoutesModule, + PersonalizationModule, + ProductModule, + ProductOccModule, + SmartEditModule, + UserModule, + UserOccModule, +} from '@spartacus/core'; +import { StorefinderFeatureModule } from './features/storefinder-feature.module'; +import { AdministrationFeatureModule } from './features/administration-feature.module'; +import { OrderApprovalFeatureModule } from './features/order-approval-feature.module'; +import { environment } from '../../environments/environment'; +import { CdcFeatureModule } from './features/cdc-feature.module'; +import { CdsFeatureModule } from './features/cds-feature.module'; +import { QualtricsFeatureModule } from './features/qualtrics-feature.module'; + +const featureModules = []; + +if (environment.b2b) { + featureModules.push(AdministrationFeatureModule, OrderApprovalFeatureModule); +} +if (environment.cdc) { + featureModules.push(CdcFeatureModule); +} +if (environment.cds) { + featureModules.push(CdsFeatureModule); +} + +@NgModule({ + imports: [ + // Auth Core + AuthModule.forRoot(), + + // Basic Cms Components + HamburgerMenuModule, + SiteContextSelectorModule, + LinkModule, + BannerModule, + CmsParagraphModule, + TabParagraphContainerModule, + BannerCarouselModule, + CategoryNavigationModule, + NavigationModule, + FooterNavigationModule, + BreadcrumbModule, + + // User Core + UserModule.forRoot(), + UserOccModule, + // User UI + UserComponentModule, + AddressBookModule, + UpdateEmailModule, + UpdatePasswordModule, + UpdateProfileModule, + CloseAccountModule, + ForgotPasswordModule, + ResetPasswordModule, + PaymentMethodsModule, + NotificationPreferenceModule, + MyInterestsModule, + StockNotificationModule, + ConsentManagementModule, + MyCouponsModule, + + // Anonymous Consents Core + AnonymousConsentsModule.forRoot(), + // Anonymous Consents UI + AnonymousConsentsDialogModule, + AnonymousConsentManagementBannerModule, + + // Product Core + ProductModule.forRoot(), + ProductOccModule, + + // Product UI + ProductDetailsPageModule, + ProductListingPageModule, + ProductListModule, + SearchBoxModule, + ProductFacetNavigationModule, + ProductTabsModule, + ProductCarouselModule, + ProductReferencesModule, + ProductImagesModule, + ProductSummaryModule, + ProductVariantsModule, + ProductIntroModule, + + // Cart Core + CartModule.forRoot(), + CartOccModule, + // Cart UI + CartComponentModule, + WishListModule, + + // Checkout Core + CheckoutModule.forRoot(), + CheckoutOccModule, + CostCenterOccModule, + // Checkout UI + CheckoutComponentModule, + OrderConfirmationModule, + + // Order + OrderHistoryModule, + OrderDetailsModule, + OrderCancellationModule, + OrderReturnModule, + ReturnRequestListModule, + ReturnRequestDetailModule, + ReplenishmentOrderHistoryModule, + ReplenishmentOrderDetailsModule, + ReplenishmentOrderConfirmationModule, + + // SmartEdit + SmartEditModule.forRoot(), + // Personalization + PersonalizationModule.forRoot(), + + // Asm Core + AsmOccModule, + // Asm UI + AsmModule, + + // Page Events + CartPageEventModule, + PageEventModule, + ProductPageEventModule, + + /************************* Opt-in features *************************/ + + ExternalRoutesModule.forRoot(), // to opt-in explicitly, is added by default schematics + JsonLdBuilderModule, + + /************************* External features *************************/ + + StorefinderFeatureModule, + QualtricsFeatureModule, + ...featureModules, + ], +}) +export class SpartacusFeaturesModule {} diff --git a/projects/storefrontapp/src/app/spartacus/spartacus.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus.module.ts new file mode 100644 index 00000000000..505323cd72d --- /dev/null +++ b/projects/storefrontapp/src/app/spartacus/spartacus.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { BaseStorefrontModule } from '@spartacus/storefront'; +import { SpartacusFeaturesModule } from './spartacus-features.module'; +import { SpartacusB2bConfigurationModule } from './spartacus-b2b-configuration.module'; +import { environment } from '../../environments/environment'; +import { SpartacusB2cConfigurationModule } from './spartacus-b2c-configuration.module'; + +let SpartacusConfigurationModule = SpartacusB2cConfigurationModule; + +if (environment.b2b) { + SpartacusConfigurationModule = SpartacusB2bConfigurationModule; +} + +@NgModule({ + imports: [ + BaseStorefrontModule, + SpartacusFeaturesModule, + SpartacusConfigurationModule, + ], + exports: [BaseStorefrontModule], +}) +export class SpartacusModule {} diff --git a/projects/storefrontapp/src/environments/b2b/b2b.feature.ts b/projects/storefrontapp/src/environments/b2b/b2b.feature.ts deleted file mode 100644 index 1f3167b4393..00000000000 --- a/projects/storefrontapp/src/environments/b2b/b2b.feature.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ConfigModule } from '@spartacus/core'; -import { - organizationTranslationChunksConfig, - organizationTranslations, -} from '@spartacus/organization/administration/assets'; -import { AdministrationRootModule } from '@spartacus/organization/administration/root'; -import { - orderApprovalTranslationChunksConfig, - orderApprovalTranslations, -} from '@spartacus/organization/order-approval/assets'; -import { OrderApprovalRootModule } from '@spartacus/organization/order-approval/root'; -import { B2bStorefrontModule } from '@spartacus/setup'; -import { FeatureEnvironment } from '../models/feature.model'; - -export const b2bFeature: FeatureEnvironment = { - imports: [ - AdministrationRootModule, - OrderApprovalRootModule, - - B2bStorefrontModule.withConfig({ - context: { - urlParameters: ['baseSite', 'language', 'currency'], - baseSite: ['powertools-spa'], - }, - - featureModules: { - organizationAdministration: { - module: () => - import('@spartacus/organization/administration').then( - (m) => m.AdministrationModule - ), - }, - organizationOrderApproval: { - module: () => - import('@spartacus/organization/order-approval').then( - (m) => m.OrderApprovalModule - ), - }, - }, - - i18n: { - resources: organizationTranslations, - chunks: organizationTranslationChunksConfig, - fallbackLang: 'en', - }, - }), - ConfigModule.withConfig({ - i18n: { - resources: orderApprovalTranslations, - chunks: orderApprovalTranslationChunksConfig, - fallbackLang: 'en', - }, - }), - ], -}; diff --git a/projects/storefrontapp/src/environments/b2c/b2c.feature.ts b/projects/storefrontapp/src/environments/b2c/b2c.feature.ts deleted file mode 100644 index 9201ee97389..00000000000 --- a/projects/storefrontapp/src/environments/b2c/b2c.feature.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { QualtricsRootModule } from '@spartacus/qualtrics/root'; -import { - storeFinderTranslationChunksConfig, - storeFinderTranslations, -} from '@spartacus/storefinder/assets'; -import { StoreFinderRootModule } from '@spartacus/storefinder/root'; -import { B2cStorefrontModule } from '@spartacus/storefront'; -import { FeatureEnvironment } from '../models/feature.model'; - -export const b2cFeature: FeatureEnvironment = { - imports: [ - B2cStorefrontModule.withConfig({ - context: { - urlParameters: ['baseSite', 'language', 'currency'], - baseSite: [ - 'electronics-spa', - 'electronics', - 'apparel-de', - 'apparel-uk', - 'apparel-uk-spa', - ], - }, - cart: { - selectiveCart: { - enabled: true, - }, - }, - - featureModules: { - storeFinder: { - module: () => - import('@spartacus/storefinder').then((m) => m.StoreFinderModule), - }, - qualtrics: { - module: () => - import('@spartacus/qualtrics').then((m) => m.QualtricsModule), - }, - }, - i18n: { - resources: storeFinderTranslations, - chunks: storeFinderTranslationChunksConfig, - }, - }), - StoreFinderRootModule, - QualtricsRootModule, - ], -}; diff --git a/projects/storefrontlib/src/base-storefront.module.ts b/projects/storefrontlib/src/base-storefront.module.ts new file mode 100644 index 00000000000..24cc361acbf --- /dev/null +++ b/projects/storefrontlib/src/base-storefront.module.ts @@ -0,0 +1,38 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { GlobalMessageComponentModule } from './cms-components/misc/global-message/global-message.module'; +import { OutletModule } from './cms-structure/outlet/outlet.module'; +import { OutletRefModule } from './cms-structure/outlet/outlet-ref/outlet-ref.module'; +import { PageLayoutModule } from './cms-structure/page/page-layout/page-layout.module'; +import { PageSlotModule } from './cms-structure/page/slot/page-slot.module'; +import { PwaModule } from './cms-structure/pwa/pwa.module'; +import { RoutingModule } from './cms-structure/routing/routing.module'; +import { SeoModule } from './cms-structure/seo/seo.module'; +import { LayoutModule } from './layout/layout.module'; +import { SkipLinkModule } from './layout/a11y/skip-link/skip-link.module'; +import { KeyboardFocusModule } from './layout/a11y/keyboard-focus/keyboard-focus.module'; +import { MediaModule } from './shared/components/media/media.module'; +import { BaseCoreModule } from '@spartacus/core'; + +@NgModule({ + declarations: [], + imports: [ + BaseCoreModule.forRoot(), + RouterModule, + GlobalMessageComponentModule, + OutletModule, + OutletRefModule, + PwaModule, + PageLayoutModule, + SeoModule, + PageSlotModule, + SkipLinkModule, + KeyboardFocusModule, + LayoutModule, + RoutingModule.forRoot(), + MediaModule.forRoot(), + OutletModule.forRoot(), + ], + exports: [LayoutModule], +}) +export class BaseStorefrontModule {} diff --git a/projects/storefrontlib/src/cms-components/cms-lib.module.ts b/projects/storefrontlib/src/cms-components/cms-lib.module.ts index 337500122f4..3b337d35a60 100644 --- a/projects/storefrontlib/src/cms-components/cms-lib.module.ts +++ b/projects/storefrontlib/src/cms-components/cms-lib.module.ts @@ -60,6 +60,9 @@ import { ProductVariantsModule } from './product/product-variants/product-varian import { UserComponentModule } from './user/user.module'; import { WishListModule } from './wish-list/wish-list.module'; +/** + * @deprecated since 3.1, use individual imports instead + */ @NgModule({ imports: [ AnonymousConsentManagementBannerModule, diff --git a/projects/storefrontlib/src/cms-components/product/product-list/container/product-list.component.ts b/projects/storefrontlib/src/cms-components/product/product-list/container/product-list.component.ts index b986c41f36f..830dc8404d2 100644 --- a/projects/storefrontlib/src/cms-components/product/product-list/container/product-list.component.ts +++ b/projects/storefrontlib/src/cms-components/product/product-list/container/product-list.component.ts @@ -29,7 +29,7 @@ export class ProductListComponent implements OnInit, OnDestroy { ) {} ngOnInit(): void { - this.isInfiniteScroll = this.scrollConfig.view.infiniteScroll.active; + this.isInfiniteScroll = this.scrollConfig.view?.infiniteScroll?.active; this.subscription.add( this.pageLayoutService.templateName$ diff --git a/projects/storefrontlib/src/cms-components/product/product-list/container/product-scroll/product-scroll.component.ts b/projects/storefrontlib/src/cms-components/product/product-list/container/product-scroll/product-scroll.component.ts index 7d442e80ee4..2382084ef48 100644 --- a/projects/storefrontlib/src/cms-components/product/product-list/container/product-scroll/product-scroll.component.ts +++ b/projects/storefrontlib/src/cms-components/product/product-list/container/product-scroll/product-scroll.component.ts @@ -70,8 +70,8 @@ export class ProductScrollComponent implements OnDestroy { } private setComponentConfigurations(scrollConfig: ViewConfig): void { - const isButton = scrollConfig.view.infiniteScroll.showMoreButton; - const configProductLimit = scrollConfig.view.infiniteScroll.productLimit; + const isButton = scrollConfig.view?.infiniteScroll?.showMoreButton; + const configProductLimit = scrollConfig.view?.infiniteScroll?.productLimit; //Display "show more" button every time when button configuration is true //Otherwise, only display "show more" when the configuration product limit is reached diff --git a/projects/storefrontlib/src/events/events.module.ts b/projects/storefrontlib/src/events/events.module.ts index 1381c77474f..179d2d7c5ac 100644 --- a/projects/storefrontlib/src/events/events.module.ts +++ b/projects/storefrontlib/src/events/events.module.ts @@ -3,6 +3,9 @@ import { CartPageEventModule } from './cart/cart-page-event.module'; import { PageEventModule } from './page/page-event.module'; import { ProductPageEventModule } from './product/product-page-event.module'; +/** + * @deprecated since 3.1, use individual imports instead + */ @NgModule({ imports: [CartPageEventModule, PageEventModule, ProductPageEventModule], }) diff --git a/projects/storefrontlib/src/layout/main/main.module.ts b/projects/storefrontlib/src/layout/main/main.module.ts index 83c74fe7282..f0e41fa8b40 100755 --- a/projects/storefrontlib/src/layout/main/main.module.ts +++ b/projects/storefrontlib/src/layout/main/main.module.ts @@ -14,6 +14,9 @@ import { KeyboardFocusModule } from '../a11y/keyboard-focus/keyboard-focus.modul import { SkipLinkModule } from '../a11y/skip-link/skip-link.module'; import { StorefrontComponent } from './storefront.component'; +/** + * @deprecated since 3.1, see https://sap.github.io/spartacus-docs/getting-started/reference-app-structure + */ @NgModule({ imports: [ CommonModule, diff --git a/projects/storefrontlib/src/public_api.ts b/projects/storefrontlib/src/public_api.ts index a71804ab082..cf2e75906fd 100644 --- a/projects/storefrontlib/src/public_api.ts +++ b/projects/storefrontlib/src/public_api.ts @@ -12,6 +12,7 @@ export * from './recipes/storefront.module'; export * from './shared/index'; export * from './storefront-config'; export * from './utils/index'; +export * from './base-storefront.module'; /** AUGMENTABLE_TYPES_START */ export { BREAKPOINT } from './layout/config/layout-config'; diff --git a/projects/storefrontlib/src/recipes/storefront-foundation.module.ts b/projects/storefrontlib/src/recipes/storefront-foundation.module.ts index c87c42cbd1b..350d2d4d6b8 100644 --- a/projects/storefrontlib/src/recipes/storefront-foundation.module.ts +++ b/projects/storefrontlib/src/recipes/storefront-foundation.module.ts @@ -19,11 +19,16 @@ import { } from '@spartacus/core'; import { OutletModule } from '../cms-structure/outlet/outlet.module'; import { RoutingModule } from '../cms-structure/routing/routing.module'; -import { EventsModule } from '../events/events.module'; import { LayoutModule } from '../layout/layout.module'; import { MediaModule } from '../shared/components/media/media.module'; import { ViewConfigModule } from '../shared/config/view-config.module'; +import { CartPageEventModule } from '../events/cart/cart-page-event.module'; +import { PageEventModule } from '../events/page/page-event.module'; +import { ProductPageEventModule } from '../events/product/product-page-event.module'; +/** + * @deprecated since 3.1, see https://sap.github.io/spartacus-docs/getting-started/reference-app-structure + */ @NgModule({ imports: [ StateModule.forRoot(), @@ -42,11 +47,13 @@ import { ViewConfigModule } from '../shared/config/view-config.module'; UserModule.forRoot(), ProductModule.forRoot(), ViewConfigModule.forRoot(), - FeaturesConfigModule.forRoot('2.0'), + FeaturesConfigModule.forRoot(), LayoutModule, MediaModule.forRoot(), - EventsModule, OutletModule.forRoot(), + CartPageEventModule, + PageEventModule, + ProductPageEventModule, ], exports: [LayoutModule], providers: [...provideConfigFromMetaTags()], diff --git a/projects/storefrontlib/src/recipes/storefront.module.ts b/projects/storefrontlib/src/recipes/storefront.module.ts index 87992983549..cbc64bf7f99 100644 --- a/projects/storefrontlib/src/recipes/storefront.module.ts +++ b/projects/storefrontlib/src/recipes/storefront.module.ts @@ -17,6 +17,9 @@ import { MainModule } from '../layout/main/main.module'; import { StorefrontConfig } from '../storefront-config'; import { StorefrontFoundationModule } from './storefront-foundation.module'; +/** + * @deprecated since 3.1, see https://sap.github.io/spartacus-docs/getting-started/reference-app-structure + */ @NgModule({ imports: [ RouterModule.forRoot([], { diff --git a/projects/storefrontlib/src/shared/config/view-config.module.ts b/projects/storefrontlib/src/shared/config/view-config.module.ts index 0ca7e67de29..adbdbd9adc4 100644 --- a/projects/storefrontlib/src/shared/config/view-config.module.ts +++ b/projects/storefrontlib/src/shared/config/view-config.module.ts @@ -1,6 +1,11 @@ import { ModuleWithProviders, NgModule } from '@angular/core'; import { provideDefaultConfig } from '@spartacus/core'; +/** + * @deprecated since 3.1 + * + * TODO: remove in 4.0 + */ @NgModule({}) export class ViewConfigModule { static forRoot(): ModuleWithProviders { From 03d9631f19f97f0bef2b2b408214b669c0ba7e69 Mon Sep 17 00:00:00 2001 From: Marcin Lasak Date: Tue, 26 Jan 2021 19:52:54 +0100 Subject: [PATCH 24/30] chore: Fix errors found by config script (#10739) Closes #10608 --- ci-scripts/validate-lint.sh | 11 -- core-libs/setup/package.json | 12 +- .../core/services/user-group.service.spec.ts | 3 +- feature-libs/organization/package.json | 28 +++-- feature-libs/product/package.json | 16 +-- feature-libs/product/tsconfig.lib.json | 5 +- feature-libs/qualtrics/package.json | 21 ++-- .../store-finder-list-item.component.ts | 3 +- feature-libs/storefinder/package.json | 25 ++-- integration-libs/cdc/package.json | 18 +-- integration-libs/cds/package.json | 15 +-- package.json | 3 + projects/assets/package.json | 14 +-- projects/core/package.json | 16 +-- projects/core/src/occ/occ.module.ts | 14 +-- projects/incubator/package.json | 1 - projects/incubator/tsconfig.lib.json | 5 +- projects/schematics/package.json | 21 ++-- projects/storefrontlib/package.json | 18 ++- projects/storefrontstyles/build-styles.js | 21 ---- projects/storefrontstyles/importer.js | 15 --- projects/storefrontstyles/package.json | 20 ++- projects/storefrontstyles/yarn.lock | 117 ------------------ projects/vendor/package.json | 4 +- tools/config/index.ts | 6 +- tools/config/manage-dependencies.ts | 27 +++- tsconfig.compodoc.json | 8 +- yarn.lock | 2 +- 28 files changed, 161 insertions(+), 308 deletions(-) delete mode 100644 projects/storefrontstyles/build-styles.js delete mode 100644 projects/storefrontstyles/importer.js delete mode 100644 projects/storefrontstyles/yarn.lock diff --git a/ci-scripts/validate-lint.sh b/ci-scripts/validate-lint.sh index 0d64fa8d095..ddd7de2e5c3 100755 --- a/ci-scripts/validate-lint.sh +++ b/ci-scripts/validate-lint.sh @@ -2,16 +2,6 @@ set -e set -o pipefail -function validatestyles { - echo "-----" - echo "Validating styles app" - pushd projects/storefrontstyles - yarn - yarn sass - rm -rf temp-scss - popd -} - function validateStylesLint { echo "----" echo "Running styleslint" @@ -60,7 +50,6 @@ else exit 1 fi -validatestyles validateStylesLint echo "Validating code linting" diff --git a/core-libs/setup/package.json b/core-libs/setup/package.json index bd1cc13f8c8..6fc1e8fc709 100644 --- a/core-libs/setup/package.json +++ b/core-libs/setup/package.json @@ -2,7 +2,6 @@ "name": "@spartacus/setup", "version": "3.0.0", "description": "Includes features that makes Spartacus and it's setup easier and streamlined.", - "homepage": "https://github.com/SAP/spartacus", "keywords": [ "spartacus", "framework", @@ -10,20 +9,23 @@ "setup", "recipe" ], + "homepage": "https://github.com/SAP/spartacus", + "repository": "https://github.com/SAP/spartacus/tree/develop/core-libs/setup", "license": "Apache-2.0", - "publishConfig": { - "access": "public" + "dependencies": { + "tslib": "^2.0.0" }, - "repository": "https://github.com/SAP/spartacus/tree/develop/core-libs/setup", "peerDependencies": { "@angular/common": "^10.1.0", "@angular/core": "^10.1.0", - "rxjs": "^6.6.0", "@spartacus/core": "3.0.0", "@spartacus/storefront": "3.0.0" }, "optionalDependencies": { "@nguniversal/express-engine": "^10.1.0", "express": "^4.15.0" + }, + "publishConfig": { + "access": "public" } } diff --git a/feature-libs/organization/administration/core/services/user-group.service.spec.ts b/feature-libs/organization/administration/core/services/user-group.service.spec.ts index 5e727f096b5..17faf9cf537 100644 --- a/feature-libs/organization/administration/core/services/user-group.service.spec.ts +++ b/feature-libs/organization/administration/core/services/user-group.service.spec.ts @@ -7,8 +7,7 @@ import { SearchConfig, UserIdService, } from '@spartacus/core'; -import { BehaviorSubject } from 'rxjs'; -import { of } from 'rxjs/internal/observable/of'; +import { BehaviorSubject, of } from 'rxjs'; import { take } from 'rxjs/operators'; import { OrganizationItemStatus, Permission, UserGroup } from '../model'; import { LoadStatus } from '../model/organization-item-status'; diff --git a/feature-libs/organization/package.json b/feature-libs/organization/package.json index b19e59c7722..4812a649b02 100644 --- a/feature-libs/organization/package.json +++ b/feature-libs/organization/package.json @@ -2,36 +2,42 @@ "name": "@spartacus/organization", "version": "3.0.0", "description": "Organization library for Spartacus", - "homepage": "https://github.com/SAP/spartacus", "keywords": [ "spartacus", "framework", "storefront", "organization" ], + "homepage": "https://github.com/SAP/spartacus", + "repository": "https://github.com/SAP/spartacus/tree/develop/feature-libs/organization", + "license": "Apache-2.0", "scripts": { - "clean:schematics": "../../node_modules/.bin/rimraf \"schematics/**/*.js\" \"schematics/**/*.js.map\" \"schematics/**/*.d.ts\"", "build:schematics": "yarn clean:schematics && ../../node_modules/.bin/tsc -p ./tsconfig.schematics.json", + "clean:schematics": "../../node_modules/.bin/rimraf \"schematics/**/*.js\" \"schematics/**/*.js.map\" \"schematics/**/*.d.ts\"", "test:schematics": "yarn --cwd ../../projects/schematics/ run clean && yarn clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, - "license": "Apache-2.0", - "publishConfig": { - "access": "public" + "dependencies": { + "tslib": "^2.0.0" }, - "repository": "https://github.com/SAP/spartacus/tree/develop/feature-libs/organization", - "schematics": "./schematics/collection.json", "peerDependencies": { "@angular-devkit/schematics": "^10.1.0", "@angular/common": "^10.1.0", "@angular/core": "^10.1.0", + "@angular/forms": "^10.1.0", "@angular/router": "^10.1.0", + "@ng-select/ng-select": "^5.0.9", + "@ngrx/effects": "^10.0.0", + "@ngrx/store": "^10.0.0", + "@schematics/angular": "^10.1.0", "@spartacus/core": "3.0.0", "@spartacus/schematics": "3.0.0", "@spartacus/storefront": "3.0.0", "bootstrap": "^4.0", - "rxjs": "^6.6.0" + "rxjs": "^6.6.0", + "typescript": "~4.0.2" }, - "dependencies": { - "tslib": "^2.0.0" - } + "publishConfig": { + "access": "public" + }, + "schematics": "./schematics/collection.json" } diff --git a/feature-libs/product/package.json b/feature-libs/product/package.json index 006b3647888..ed7694eea01 100644 --- a/feature-libs/product/package.json +++ b/feature-libs/product/package.json @@ -2,26 +2,22 @@ "name": "@spartacus/product", "version": "3.0.0-next.0", "description": "Product library for Spartacus", - "homepage": "https://github.com/SAP/spartacus", "keywords": [ "spartacus", "framework", "storefront", "product" ], - "license": "Apache-2.0", - "publishConfig": { - "access": "public" - }, + "homepage": "https://github.com/SAP/spartacus", "repository": "https://github.com/SAP/spartacus/tree/develop/feature-libs/product", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { - "@angular/common": "^10.1.0", - "@angular/core": "^10.1.0", - "rxjs": "^6.6.0", - "@spartacus/core": "3.0.0-next.0", - "@spartacus/storefront": "3.0.0-next.0" + "@angular/core": "^10.1.0" + }, + "publishConfig": { + "access": "public" } } diff --git a/feature-libs/product/tsconfig.lib.json b/feature-libs/product/tsconfig.lib.json index 298dd886ba1..78b2cccdaed 100644 --- a/feature-libs/product/tsconfig.lib.json +++ b/feature-libs/product/tsconfig.lib.json @@ -12,10 +12,7 @@ "importHelpers": true, "types": [], "lib": ["dom", "esnext"], - "paths": { - "@spartacus/core": ["dist/core"], - "@spartacus/storefront": ["dist/storefrontlib"] - } + "paths": {} }, "angularCompilerOptions": { "skipTemplateCodegen": true, diff --git a/feature-libs/qualtrics/package.json b/feature-libs/qualtrics/package.json index 9d971549e6c..36c5d075f5c 100644 --- a/feature-libs/qualtrics/package.json +++ b/feature-libs/qualtrics/package.json @@ -2,7 +2,6 @@ "name": "@spartacus/qualtrics", "version": "3.1.0", "description": "Qualtrics library for Spartacus", - "homepage": "https://github.com/SAP/spartacus", "keywords": [ "spartacus", "framework", @@ -11,27 +10,29 @@ "personalized", "management" ], + "homepage": "https://github.com/SAP/spartacus", + "repository": "https://github.com/SAP/spartacus/tree/develop/feature-libs/qualtrics", + "license": "Apache-2.0", "scripts": { - "clean:schematics": "../../node_modules/.bin/rimraf \"schematics/**/*.js\" \"schematics/**/*.js.map\" \"schematics/**/*.d.ts\"", "build:schematics": "yarn clean:schematics && ../../node_modules/.bin/tsc -p ./tsconfig.schematics.json", + "clean:schematics": "../../node_modules/.bin/rimraf \"schematics/**/*.js\" \"schematics/**/*.js.map\" \"schematics/**/*.d.ts\"", "test:schematics": "yarn --cwd ../../projects/schematics/ run clean && yarn clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, - "license": "Apache-2.0", - "publishConfig": { - "access": "public" + "dependencies": { + "tslib": "^2.0.0" }, - "repository": "https://github.com/SAP/spartacus/tree/develop/feature-libs/qualtrics", - "schematics": "./schematics/collection.json", "peerDependencies": { "@angular-devkit/schematics": "^10.1.0", "@angular/common": "^10.1.0", "@angular/core": "^10.1.0", + "@schematics/angular": "^10.1.0", "@spartacus/core": "3.0.0", "@spartacus/schematics": "3.0.0", "bootstrap": "^4.0", "rxjs": "^6.6.0" }, - "dependencies": { - "tslib": "^2.0.0" - } + "publishConfig": { + "access": "public" + }, + "schematics": "./schematics/collection.json" } diff --git a/feature-libs/storefinder/components/store-finder-list-item/store-finder-list-item.component.ts b/feature-libs/storefinder/components/store-finder-list-item/store-finder-list-item.component.ts index 49d4e01ffff..c1a8c008f36 100644 --- a/feature-libs/storefinder/components/store-finder-list-item/store-finder-list-item.component.ts +++ b/feature-libs/storefinder/components/store-finder-list-item/store-finder-list-item.component.ts @@ -1,7 +1,6 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; - -import { AbstractStoreItemComponent } from '../abstract-store-item/abstract-store-item.component'; import { StoreDataService } from '@spartacus/storefinder/core'; +import { AbstractStoreItemComponent } from '../abstract-store-item/abstract-store-item.component'; @Component({ selector: 'cx-store-finder-list-item', diff --git a/feature-libs/storefinder/package.json b/feature-libs/storefinder/package.json index 05a63f99635..580e0a7bfd3 100644 --- a/feature-libs/storefinder/package.json +++ b/feature-libs/storefinder/package.json @@ -2,36 +2,41 @@ "name": "@spartacus/storefinder", "version": "3.0.0", "description": "Store finder feature library for Spartacus", - "homepage": "https://github.com/SAP/spartacus", "keywords": [ "spartacus", "framework", "storefront", "storefinder" ], + "homepage": "https://github.com/SAP/spartacus", + "repository": "https://github.com/SAP/spartacus/tree/develop/feature-libs/storefinder", + "license": "Apache-2.0", "scripts": { - "clean:schematics": "../../node_modules/.bin/rimraf \"schematics/**/*.js\" \"schematics/**/*.js.map\" \"schematics/**/*.d.ts\"", "build:schematics": "yarn clean:schematics && ../../node_modules/.bin/tsc -p ./tsconfig.schematics.json", + "clean:schematics": "../../node_modules/.bin/rimraf \"schematics/**/*.js\" \"schematics/**/*.js.map\" \"schematics/**/*.d.ts\"", "test:schematics": "yarn --cwd ../../projects/schematics/ run clean && yarn clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, - "license": "Apache-2.0", - "publishConfig": { - "access": "public" + "dependencies": { + "tslib": "^2.0.0" }, - "repository": "https://github.com/SAP/spartacus/tree/develop/feature-libs/storefinder", - "schematics": "./schematics/collection.json", "peerDependencies": { "@angular-devkit/schematics": "^10.1.0", "@angular/common": "^10.1.0", "@angular/core": "^10.1.0", + "@angular/forms": "^10.1.0", "@angular/router": "^10.1.0", "@ng-bootstrap/ng-bootstrap": "^7.0.0", + "@ngrx/effects": "^10.0.0", + "@ngrx/store": "^10.0.0", + "@schematics/angular": "^10.1.0", "@spartacus/core": "3.0.0", "@spartacus/schematics": "3.0.0", "@spartacus/storefront": "3.0.0", + "bootstrap": "^4.3.1", "rxjs": "^6.6.0" }, - "dependencies": { - "tslib": "^2.0.0" - } + "publishConfig": { + "access": "public" + }, + "schematics": "./schematics/collection.json" } diff --git a/integration-libs/cdc/package.json b/integration-libs/cdc/package.json index 4babb37ea32..6c2d1870c57 100644 --- a/integration-libs/cdc/package.json +++ b/integration-libs/cdc/package.json @@ -2,7 +2,6 @@ "name": "@spartacus/cdc", "version": "0.300.0-next.6", "description": "Customer Data Cloud Integration library for Spartacus", - "homepage": "https://github.com/SAP/spartacus", "keywords": [ "spartacus", "framework", @@ -11,11 +10,9 @@ "gigya", "cdc" ], - "license": "Apache-2.0", - "publishConfig": { - "access": "public" - }, + "homepage": "https://github.com/SAP/spartacus", "repository": "https://github.com/SAP/spartacus/tree/develop/integration-libs/cdc", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.0.0" }, @@ -23,8 +20,13 @@ "@angular/common": "^10.1.0", "@angular/core": "^10.1.0", "@angular/router": "^10.1.0", - "rxjs": "^6.6.0", - "@spartacus/core": "3.0.0-next.6", - "@spartacus/storefront": "3.0.0-next.6" + "@ngrx/effects": "^10.0.0", + "@ngrx/store": "^10.0.0", + "@spartacus/core": "3.0.0", + "@spartacus/storefront": "3.0.0", + "rxjs": "^6.6.0" + }, + "publishConfig": { + "access": "public" } } diff --git a/integration-libs/cds/package.json b/integration-libs/cds/package.json index d4908194658..6ed8f754df9 100644 --- a/integration-libs/cds/package.json +++ b/integration-libs/cds/package.json @@ -2,7 +2,6 @@ "name": "@spartacus/cds", "version": "3.0.0-next.6", "description": "Context Driven Service library for Spartacus", - "homepage": "https://github.com/SAP/spartacus", "keywords": [ "spartacus", "framework", @@ -12,11 +11,9 @@ "context-driven services", "cds" ], - "license": "Apache-2.0", - "publishConfig": { - "access": "public" - }, + "homepage": "https://github.com/SAP/spartacus", "repository": "https://github.com/SAP/spartacus/tree/develop/integration-libs/cds", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.0.0" }, @@ -24,8 +21,12 @@ "@angular/common": "^10.1.0", "@angular/core": "^10.1.0", "@angular/router": "^10.1.0", - "@spartacus/core": "3.0.0-next.6", - "@spartacus/storefront": "3.0.0-next.6", + "@ngrx/store": "^10.0.0", + "@spartacus/core": "3.0.0", + "@spartacus/storefront": "3.0.0", "rxjs": "^6.6.0" + }, + "publishConfig": { + "access": "public" } } diff --git a/package.json b/package.json index ccd0e7c2f1f..36073b5f4a9 100644 --- a/package.json +++ b/package.json @@ -148,12 +148,14 @@ "@angular-builders/dev-server": "^7.3.1", "@angular-devkit/build-angular": "^0.1002.1", "@angular-devkit/build-ng-packagr": "~0.1002.1", + "@angular-devkit/core": "10.2.1", "@angular-devkit/schematics": "^10.2.1", "@angular/cli": "^10.2.1", "@angular/compiler-cli": "^10.2.4", "@angular/language-service": "^10.2.4", "@ngrx/store-devtools": "^10.0.0", "@nguniversal/builders": "^10.1.0", + "@schematics/angular": "10.2.1", "@types/express": "^4.17.0", "@types/i18next": "^12.1.0", "@types/jasmine": "~3.5.0", @@ -172,6 +174,7 @@ "ejs": "^2.6.2", "enquirer": "^2.3.6", "faker": "^4.1.0", + "fs-extra": "^9.0.1", "gh-got": "^8.0.1", "gh-pages": "^2.1.1", "git-raw-commits": "^2.0.0", diff --git a/projects/assets/package.json b/projects/assets/package.json index 3e43ee97869..4832b580298 100644 --- a/projects/assets/package.json +++ b/projects/assets/package.json @@ -3,16 +3,16 @@ "version": "3.0.0", "homepage": "https://github.com/SAP/spartacus", "repository": "https://github.com/SAP/spartacus/tree/develop/projects/assets", - "publishConfig": { - "access": "public" + "scripts": { + "build": "ng build assets --prod && yarn generate:translations:ts-2-json", + "generate:translations:properties-2-ts": "ts-node ./generate-translations-properties-2-ts && cd ../.. && npx prettier \"./projects/assets/src/translations/**/*.ts\" --write", + "generate:translations:ts-2-json": "ts-node ./generate-translations-ts-2-json", + "generate:translations:ts-2-properties": "ts-node ./generate-translations-ts-2-properties" }, "dependencies": { "tslib": "^2.0.0" }, - "scripts": { - "build": "ng build assets --prod && yarn generate:translations:ts-2-json", - "generate:translations:ts-2-json": "ts-node ./generate-translations-ts-2-json", - "generate:translations:ts-2-properties": "ts-node ./generate-translations-ts-2-properties", - "generate:translations:properties-2-ts": "ts-node ./generate-translations-properties-2-ts && cd ../.. && npx prettier \"./projects/assets/src/translations/**/*.ts\" --write" + "publishConfig": { + "access": "public" } } diff --git a/projects/core/package.json b/projects/core/package.json index e87cbb804ec..3f9c58315f1 100644 --- a/projects/core/package.json +++ b/projects/core/package.json @@ -2,18 +2,15 @@ "name": "@spartacus/core", "version": "3.0.0", "description": "Spartacus - the core framework", - "homepage": "https://github.com/SAP/spartacus", "keywords": [ "spartacus", "storefront", "angular", "framework" ], - "license": "Apache-2.0", - "publishConfig": { - "access": "public" - }, + "homepage": "https://github.com/SAP/spartacus", "repository": "https://github.com/SAP/spartacus/tree/develop/projects/core", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.0.0" }, @@ -22,12 +19,15 @@ "@angular/core": "^10.1.0", "@angular/platform-browser": "^10.1.0", "@angular/router": "^10.1.0", - "angular-oauth2-oidc": "^10.0.1", - "rxjs": "^6.6.0", "@ngrx/effects": "^10.0.0", "@ngrx/router-store": "^10.0.0", "@ngrx/store": "^10.0.0", + "angular-oauth2-oidc": "^10.0.1", "i18next": "^19.3.4", - "i18next-xhr-backend": "^3.2.2" + "i18next-xhr-backend": "^3.2.2", + "rxjs": "^6.6.0" + }, + "publishConfig": { + "access": "public" } } diff --git a/projects/core/src/occ/occ.module.ts b/projects/core/src/occ/occ.module.ts index 803b5802155..7bc38265a57 100644 --- a/projects/core/src/occ/occ.module.ts +++ b/projects/core/src/occ/occ.module.ts @@ -1,19 +1,19 @@ import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { ModuleWithProviders, NgModule } from '@angular/core'; +import { provideDefaultConfig } from '../config/config-providers'; import { provideConfigValidator } from '../config/config-validator/config-validator'; -import { OccConfigLoaderModule } from './config-loader/occ-config-loader.module'; -import { defaultOccConfig } from './config/default-occ-config'; -import { occConfigValidator } from './config/occ-config-validator'; -import { WithCredentialsInterceptor } from './interceptors/with-credentials.interceptor'; import { AsmOccModule } from './adapters/asm/asm-occ.module'; -import { CmsOccModule } from './adapters/cms/cms-occ.module'; import { CartOccModule } from './adapters/cart/cart-occ.module'; import { CheckoutOccModule } from './adapters/checkout/checkout-occ.module'; +import { CmsOccModule } from './adapters/cms/cms-occ.module'; +import { CostCenterOccModule } from './adapters/cost-center/cost-center-occ.module'; import { ProductOccModule } from './adapters/product/product-occ.module'; import { SiteContextOccModule } from './adapters/site-context/site-context-occ.module'; import { UserOccModule } from './adapters/user/user-occ.module'; -import { provideDefaultConfig } from '../config/config-providers'; -import { CostCenterOccModule } from './adapters/cost-center/cost-center-occ.module'; +import { OccConfigLoaderModule } from './config-loader/occ-config-loader.module'; +import { defaultOccConfig } from './config/default-occ-config'; +import { occConfigValidator } from './config/occ-config-validator'; +import { WithCredentialsInterceptor } from './interceptors/with-credentials.interceptor'; /** * @deprecated since 3.1, use individual imports instead diff --git a/projects/incubator/package.json b/projects/incubator/package.json index 5bc7f3b68c2..6e5cebb4b37 100644 --- a/projects/incubator/package.json +++ b/projects/incubator/package.json @@ -6,7 +6,6 @@ "tslib": "^2.0.0" }, "peerDependencies": { - "@angular/common": "^10.1.0", "@angular/core": "^10.1.0" } } diff --git a/projects/incubator/tsconfig.lib.json b/projects/incubator/tsconfig.lib.json index f1ea475b12c..fc2394783d1 100644 --- a/projects/incubator/tsconfig.lib.json +++ b/projects/incubator/tsconfig.lib.json @@ -12,10 +12,7 @@ "importHelpers": true, "types": [], "lib": ["esnext", "dom"], - "paths": { - "@spartacus/core": ["dist/core"], - "@spartacus/storefront": ["dist/storefrontlib"] - } + "paths": {} }, "angularCompilerOptions": { "skipTemplateCodegen": true, diff --git a/projects/schematics/package.json b/projects/schematics/package.json index c9f0cf2a66b..9eb7b9054cc 100644 --- a/projects/schematics/package.json +++ b/projects/schematics/package.json @@ -2,20 +2,20 @@ "name": "@spartacus/schematics", "version": "3.0.0", "description": "Spartacus schematics", - "homepage": "https://github.com/SAP/spartacus", - "repository": "https://github.com/SAP/spartacus/tree/develop/projects/schematics", "keywords": [ "spartacus", "schematics" ], + "homepage": "https://github.com/SAP/spartacus", + "repository": "https://github.com/SAP/spartacus/tree/develop/projects/schematics", + "license": "MIT", + "author": "", + "main": "src/public_api.js", "scripts": { - "clean": "../../node_modules/.bin/rimraf \"src/**/*.js\" \"src/**/*.js.map\" \"src/**/*.d.ts\"", "build": "yarn clean && ../../node_modules/.bin/tsc -p ./tsconfig.schematics.json", + "clean": "../../node_modules/.bin/rimraf \"src/**/*.js\" \"src/**/*.js.map\" \"src/**/*.d.ts\"", "test": "yarn clean && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, - "author": "", - "license": "MIT", - "schematics": "./src/collection.json", "dependencies": { "@angular/localize": "^10.1.0", "@angular/pwa": "^0.1001.0", @@ -25,10 +25,11 @@ "peerDependencies": { "@angular-devkit/core": "^10.1.0", "@angular-devkit/schematics": "^10.1.0", - "@angular/cli": "^10.1.0", - "@angular/core": "^10.1.0", + "@angular/compiler": "^10.1.0", "@schematics/angular": "^10.1.0", - "parse5": "^6.0.1" + "parse5": "^6.0.1", + "rxjs": "^6.6.0", + "typescript": "~4.0.2" }, "ng-add": { "save": "devDependencies" @@ -50,5 +51,5 @@ "@spartacus/setup" ] }, - "main": "src/public_api.js" + "schematics": "./src/collection.json" } diff --git a/projects/storefrontlib/package.json b/projects/storefrontlib/package.json index 4e81fa34fa8..c7c6e9ed07f 100644 --- a/projects/storefrontlib/package.json +++ b/projects/storefrontlib/package.json @@ -1,36 +1,34 @@ { "name": "@spartacus/storefront", "version": "3.0.0", - "homepage": "https://github.com/SAP/spartacus", "keywords": [ "spartacus", "storefront", "angular" ], - "license": "Apache-2.0", - "publishConfig": { - "access": "public" - }, + "homepage": "https://github.com/SAP/spartacus", "repository": "https://github.com/SAP/spartacus/tree/develop/projects/storefrontlib", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@angular/common": "^10.1.0", - "@angular/compiler": "^10.1.0", "@angular/core": "^10.1.0", "@angular/forms": "^10.1.0", - "@angular/localize": "^10.1.0", "@angular/platform-browser": "^10.1.0", "@angular/router": "^10.1.0", "@angular/service-worker": "^10.1.0", "@ng-bootstrap/ng-bootstrap": "^7.0.0", "@ng-select/ng-select": "^5.0.9", "@ngrx/effects": "^10.0.0", + "@ngrx/router-store": "^10.0.0", "@ngrx/store": "^10.0.0", "@spartacus/core": "3.0.0", - "rxjs": "^6.6.0", - "zone.js": "^0.10.2", - "ngx-infinite-scroll": "^8.0.0" + "ngx-infinite-scroll": "^8.0.0", + "rxjs": "^6.6.0" + }, + "publishConfig": { + "access": "public" } } diff --git a/projects/storefrontstyles/build-styles.js b/projects/storefrontstyles/build-styles.js deleted file mode 100644 index ef2a66d5e5a..00000000000 --- a/projects/storefrontstyles/build-styles.js +++ /dev/null @@ -1,21 +0,0 @@ -const sass = require('sass'); -const fs = require('fs'); -const importer = require('./importer'); - -const file = './_index.scss'; -const outFile = './output.css'; - -sass.render( - { - file, - outFile, - importer, - }, - function (error, result) { - if (error) { - throw error; - } else { - fs.writeFile(outFile, result.css, function () {}); - } - } -); diff --git a/projects/storefrontstyles/importer.js b/projects/storefrontstyles/importer.js deleted file mode 100644 index 9fe05cc05e2..00000000000 --- a/projects/storefrontstyles/importer.js +++ /dev/null @@ -1,15 +0,0 @@ -// Script used by node sass to resolve external imputs -// Used only to test library -module.exports = function importer(url, prev, done) { - if (url[0] === '~') { - return { file: resolveModule(url.substr(1), prev) }; - } else { - return null; - } -}; - -// Resolve the import path for external modules (ex: Bootstrap) -function resolveModule(targetUrl, prev) { - var baseUrl = prev.split('projects')[0]; - return baseUrl.concat('node_modules/', targetUrl); -} diff --git a/projects/storefrontstyles/package.json b/projects/storefrontstyles/package.json index 6f4bfe135ac..3d9b18ba155 100644 --- a/projects/storefrontstyles/package.json +++ b/projects/storefrontstyles/package.json @@ -2,27 +2,23 @@ "name": "@spartacus/styles", "version": "3.0.0", "description": "Style library containing global styles", - "homepage": "https://github.com/SAP/spartacus", "keywords": [ "spartacus", "storefront", "styles" ], - "license": "Apache-2.0", - "publishConfig": { - "access": "public" - }, + "homepage": "https://github.com/SAP/spartacus", "repository": "https://github.com/SAP/spartacus/tree/develop/projects/storefrontstyles", - "scripts": { - "sass": "node ./build-styles.js" - }, - "devDependencies": { - "sass": "^1.25.0" + "license": "Apache-2.0", + "dependencies": { + "hamburgers": "^1.1.3" }, + "devDependencies": {}, "peerDependencies": { + "@ng-select/ng-select": "^5.0.9", "bootstrap": "^4.0" }, - "dependencies": { - "hamburgers": "^1.1.3" + "publishConfig": { + "access": "public" } } diff --git a/projects/storefrontstyles/yarn.lock b/projects/storefrontstyles/yarn.lock deleted file mode 100644 index a45b3f204c7..00000000000 --- a/projects/storefrontstyles/yarn.lock +++ /dev/null @@ -1,117 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -anymatch@~3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" - integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - -binary-extensions@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.1.0.tgz#30fa40c9e7fe07dbc895678cd287024dea241dd9" - integrity sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ== - -braces@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== - dependencies: - fill-range "^7.0.1" - -"chokidar@>=2.0.0 <4.0.0": - version "3.4.2" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d" - integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A== - dependencies: - anymatch "~3.1.1" - braces "~3.0.2" - glob-parent "~5.1.0" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.4.0" - optionalDependencies: - fsevents "~2.1.2" - -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== - dependencies: - to-regex-range "^5.0.1" - -fsevents@~2.1.2: - version "2.1.3" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" - integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== - -glob-parent@~5.1.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229" - integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ== - dependencies: - is-glob "^4.0.1" - -hamburgers@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/hamburgers/-/hamburgers-1.1.3.tgz#eb583ede269d3a774aa5f613442b17ea13952b84" - integrity sha512-qpfnJwZq6ATAGJEriwuyfVNgT++GG+o+3bBfPYF7F3WY452cYKbaYGUuqwhp+3kHLI6CL4VIBfj8bfbp90Lp1A== - -is-binary-path@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= - -is-glob@^4.0.1, is-glob@~4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" - integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== - dependencies: - is-extglob "^2.1.1" - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -normalize-path@^3.0.0, normalize-path@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -picomatch@^2.0.4, picomatch@^2.2.1: - version "2.2.2" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" - integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== - -readdirp@~3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada" - integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ== - dependencies: - picomatch "^2.2.1" - -sass@^1.25.0: - version "1.26.10" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.26.10.tgz#851d126021cdc93decbf201d1eca2a20ee434760" - integrity sha512-bzN0uvmzfsTvjz0qwccN1sPm2HxxpNI/Xa+7PlUEMS+nQvbyuEK7Y0qFqxlPHhiNHb1Ze8WQJtU31olMObkAMw== - dependencies: - chokidar ">=2.0.0 <4.0.0" - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" diff --git a/projects/vendor/package.json b/projects/vendor/package.json index 593a3503283..fbb52accf4d 100644 --- a/projects/vendor/package.json +++ b/projects/vendor/package.json @@ -7,7 +7,7 @@ "peerDependencies": { "@angular/common": "^10.1.0", "@angular/core": "^10.1.0", - "rxjs": "^6.6.0", - "@spartacus/core": "3.0.0-next.0" + "@spartacus/core": "3.0.0", + "rxjs": "^6.6.0" } } diff --git a/tools/config/index.ts b/tools/config/index.ts index fe241fb05bd..16c1ca01bd6 100644 --- a/tools/config/index.ts +++ b/tools/config/index.ts @@ -126,7 +126,7 @@ const program = new Command(); program .description('Check configuration in repository for inconsistencies') .option('--fix', 'Apply automatic fixes when possible') - .option('--breaking-changes', 'Allow breaking changes in transformations'); + .option('--bump-versions', 'Bump deps versions to match root package.json'); program.parse(process.argv); @@ -142,9 +142,9 @@ export type ProgramOptions = { */ fix: boolean | undefined; /** - * Sets if also breaking changes should be applied. Use for majors only. + * Sets if versions should be bumped. Use for majors only. */ - breakingChanges: boolean | undefined; + bumpVersions: boolean | undefined; }; const options: ProgramOptions = program.opts() as any; diff --git a/tools/config/manage-dependencies.ts b/tools/config/manage-dependencies.ts index 895656d4bdd..8e85fde4f2c 100644 --- a/tools/config/manage-dependencies.ts +++ b/tools/config/manage-dependencies.ts @@ -19,7 +19,7 @@ import glob from 'glob'; import postcss from 'postcss-scss'; import semver from 'semver'; import * as ts from 'typescript'; -import { PACKAGE_JSON, SPARTACUS_SCOPE } from './const'; +import { PACKAGE_JSON, SPARTACUS_SCHEMATICS, SPARTACUS_SCOPE } from './const'; import { error, Library, @@ -140,7 +140,11 @@ export function manageDependencies( const scssImports = {}; // Gather data about ts imports - const tsFilesPaths = glob.sync(`${library.directory}/**/*.ts`); + const tsFilesPaths = glob.sync(`${library.directory}/**/*.ts`, { + // Ignore assets json translation scripts + // TODO: Remove when translation script will be moved to lib builder + ignore: [`projects/assets/generate-translations-*.ts`], + }); tsFilesPaths.forEach((fileName) => { const sourceFile = ts.createSourceFile( @@ -757,6 +761,13 @@ function removeNotUsedDependenciesFromPackageJson( libraries: Record, options: ProgramOptions ): void { + // Keep these dependencies in schematics as these are used as external schematics + const externalSchematics = [ + '@angular/localize', + '@angular/pwa', + '@nguniversal/express-engine', + ]; + if (options.fix) { reportProgress('Removing unused dependencies'); } else { @@ -775,7 +786,10 @@ function removeNotUsedDependenciesFromPackageJson( Object.keys(deps).forEach((dep) => { if ( typeof lib.externalDependenciesForPackageJson[dep] === 'undefined' && - dep !== `tslib` + dep !== `tslib` && + ((lib.name === SPARTACUS_SCHEMATICS && + !externalSchematics.includes(dep)) || + lib.name !== SPARTACUS_SCHEMATICS) ) { if (options.fix) { const packageJson = lib.packageJsonContent; @@ -1081,7 +1095,7 @@ function updateDependenciesVersions( } } else { // breaking change! - if (options.breakingChanges && options.fix) { + if (options.bumpVersions && options.fix) { packageJson[type][dep] = rootDeps[dep]; updates.add(pathToPackageJson); } else if (!options.fix) { @@ -1130,9 +1144,10 @@ function updateDependenciesVersions( `All external dependencies should have the same version as in the root \`${chalk.bold( PACKAGE_JSON )}\`.`, - `Bumping to a higher dependency version is considered a breaking change!`, + `Bumping to a higher dependency version should be only done in major releases!`, + `We want to specify everywhere the lowest compatible dependency version with Spartacus.`, `This can be automatically fixed by running \`${chalk.bold( - 'yarn config:update --breaking-changes' + 'yarn config:update --bump-versions' )}\`.`, ]); } diff --git a/tsconfig.compodoc.json b/tsconfig.compodoc.json index 8ba525a0ede..60475501c53 100644 --- a/tsconfig.compodoc.json +++ b/tsconfig.compodoc.json @@ -20,9 +20,6 @@ "noUnusedLocals": true, "noUnusedParameters": true, "paths": { - "@spartacus/schematics": [ - "projects/schematics/src/public_api" - ], "@spartacus/setup": [ "core-libs/setup/public_api" ], @@ -119,6 +116,9 @@ "@spartacus/incubator": [ "projects/incubator/public_api" ], + "@spartacus/schematics": [ + "projects/schematics/src/public_api" + ], "@spartacus/storefront": [ "projects/storefrontlib/src/public_api" ], @@ -134,4 +134,4 @@ "./projects/storefrontstyles/**/*.*", "./projects/vendor/**/*.*" ] -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 0c6d7c47dbe..88ea8fa3364 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6617,7 +6617,7 @@ fs-extra@^8.0.1, fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" -fs-extra@^9.0.0: +fs-extra@^9.0.0, fs-extra@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ== From 1b22bf5376c1fc5a88877aaaf4682f1838347b90 Mon Sep 17 00:00:00 2001 From: Brian Gamboc-Javiniar Date: Tue, 26 Jan 2021 15:04:15 -0500 Subject: [PATCH 25/30] fix: Guest checkout - create a user in the end fails with no user feedback when email not unique (GH-10460) closes GH-10460 --- projects/assets/src/translations/en/common.ts | 2 ++ .../bad-request/bad-request.handler.spec.ts | 25 +++++++++++++++++++ .../bad-request/bad-request.handler.ts | 20 +++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/projects/assets/src/translations/en/common.ts b/projects/assets/src/translations/en/common.ts index 5ff9cfc67e9..4b3921876c1 100644 --- a/projects/assets/src/translations/en/common.ts +++ b/projects/assets/src/translations/en/common.ts @@ -60,6 +60,8 @@ export const common = { badGateway: 'A server error occurred. Please try again later.', badRequestPleaseLoginAgain: '{{ errorMessage }}. Please login again.', badRequestOldPasswordIncorrect: 'Old password incorrect.', + badRequestGuestDuplicateEmail: + '{{ errorMessage }} email already exist. Please checkout with a different email to register using a guest account.', conflict: 'Already exists.', forbidden: 'You are not authorized to perform this action. Please contact your administrator if you think this is a mistake.', diff --git a/projects/core/src/global-message/http-interceptors/handlers/bad-request/bad-request.handler.spec.ts b/projects/core/src/global-message/http-interceptors/handlers/bad-request/bad-request.handler.spec.ts index 19febf49dea..54cc9925ff9 100644 --- a/projects/core/src/global-message/http-interceptors/handlers/bad-request/bad-request.handler.spec.ts +++ b/projects/core/src/global-message/http-interceptors/handlers/bad-request/bad-request.handler.spec.ts @@ -95,6 +95,17 @@ class MockGlobalMessageService { remove() {} } +const MockBadGuestDuplicateEmailResponse = { + error: { + errors: [ + { + message: 'test@sap.com', + type: 'DuplicateUidError', + }, + ], + }, +} as HttpErrorResponse; + describe('BadRequestHandler', () => { let service: BadRequestHandler; let globalMessageService: GlobalMessageService; @@ -185,4 +196,18 @@ describe('BadRequestHandler', () => { service.handleError(MockRequest, MockBadCartResponseForSelectiveCart); expect(globalMessageService.add).not.toHaveBeenCalled(); }); + + it('should handle duplication of a registered email for guest checkout', () => { + service.handleError(MockRequest, MockBadGuestDuplicateEmailResponse); + expect(globalMessageService.add).toHaveBeenCalledWith( + { + key: 'httpHandlers.badRequestGuestDuplicateEmail', + params: { + errorMessage: + MockBadGuestDuplicateEmailResponse.error.errors[0].message, + }, + }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); }); diff --git a/projects/core/src/global-message/http-interceptors/handlers/bad-request/bad-request.handler.ts b/projects/core/src/global-message/http-interceptors/handlers/bad-request/bad-request.handler.ts index 376c458a4e5..6de6853fa45 100644 --- a/projects/core/src/global-message/http-interceptors/handlers/bad-request/bad-request.handler.ts +++ b/projects/core/src/global-message/http-interceptors/handlers/bad-request/bad-request.handler.ts @@ -21,6 +21,7 @@ export class BadRequestHandler extends HttpErrorHandler { this.handleBadCartRequest(request, response); this.handleValidationError(request, response); this.handleVoucherOperationError(request, response); + this.handleGuestDuplicateEmail(request, response); } protected handleBadPassword( @@ -108,6 +109,25 @@ export class BadRequestHandler extends HttpErrorHandler { }); } + protected handleGuestDuplicateEmail( + _request: HttpRequest, + response: HttpErrorResponse + ): void { + this.getErrors(response) + .filter((e) => e.type === 'DuplicateUidError') + .forEach((error) => { + this.globalMessageService.add( + { + key: 'httpHandlers.badRequestGuestDuplicateEmail', + params: { + errorMessage: error.message || '', + }, + }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); + } + protected getErrors(response: HttpErrorResponse): ErrorModel[] { return (response.error?.errors || []).filter( (error) => error.type !== 'JaloObjectNoLongerValidError' From 03839ad6d499ac5c6440d6394a6ced8f78bb8873 Mon Sep 17 00:00:00 2001 From: Parthlakhani Date: Tue, 26 Jan 2021 18:38:39 -0500 Subject: [PATCH 26/30] GH-8929 refactor: remove console logs (#10382) * GH-8929 refactor: remove console logs * GH-8929 fix: remove authguard Co-authored-by: Brian Gamboc-Javiniar --- .../routing/protected-routes/protected-routes.guard.spec.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/projects/core/src/routing/protected-routes/protected-routes.guard.spec.ts b/projects/core/src/routing/protected-routes/protected-routes.guard.spec.ts index 9665837acf2..f6a1ef66bb1 100644 --- a/projects/core/src/routing/protected-routes/protected-routes.guard.spec.ts +++ b/projects/core/src/routing/protected-routes/protected-routes.guard.spec.ts @@ -18,7 +18,6 @@ class MockProtectedRoutesService { describe('ProtectedRoutesGuard', () => { let guard: ProtectedRoutesGuard; let service: ProtectedRoutesService; - let authGuard: AuthGuard; beforeEach(() => { TestBed.configureTestingModule({ @@ -37,13 +36,11 @@ describe('ProtectedRoutesGuard', () => { guard = TestBed.inject(ProtectedRoutesGuard); service = TestBed.inject(ProtectedRoutesService); - authGuard = TestBed.inject(AuthGuard); }); describe('canActivate', () => { describe('when anticipated url is NOT protected', () => { beforeEach(() => { - console.log(guard, service, authGuard); spyOn(service, 'isUrlProtected').and.returnValue(false); }); @@ -58,7 +55,6 @@ describe('ProtectedRoutesGuard', () => { describe('when anticipated url is protected', () => { beforeEach(() => { - console.log(guard, service, authGuard); spyOn(service, 'isUrlProtected').and.returnValue(true); }); From 1c6659b93553a2b682ec78a5ab8af3925cd182b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miros=C5=82aw=20Grochowski?= Date: Wed, 27 Jan 2021 12:21:16 +0100 Subject: [PATCH 27/30] fix: ExternalJsFileLoader fix for SSR (#10755) closes #10201 --- .../external-js-file-loader.service.spec.ts | 8 +++----- .../external-js-file-loader.service.ts | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/projects/core/src/util/external-js-file-loader/external-js-file-loader.service.spec.ts b/projects/core/src/util/external-js-file-loader/external-js-file-loader.service.spec.ts index ee5da6d6fb1..6e276d59b1a 100644 --- a/projects/core/src/util/external-js-file-loader/external-js-file-loader.service.spec.ts +++ b/projects/core/src/util/external-js-file-loader/external-js-file-loader.service.spec.ts @@ -7,8 +7,10 @@ import { ExternalJsFileLoader } from './external-js-file-loader.service'; const SCRIPT_LOAD_URL = 'http://url/'; class DocumentMock { + head = { + appendChild() {}, + }; createElement() {} - appendChild() {} } describe('ExternalJsFileLoader', () => { @@ -36,7 +38,6 @@ describe('ExternalJsFileLoader', () => { it('should load script with params and load/error callbacks', () => { // given spyOn(documentMock, 'createElement').and.returnValue(jsDomElement); - spyOn(documentMock, 'appendChild').and.callThrough(); spyOn(jsDomElement, 'addEventListener').and.callThrough(); const params = { param1: 'value1', param2: 'value2' }; const callback = function () {}; @@ -65,7 +66,6 @@ describe('ExternalJsFileLoader', () => { it('should load script with params and callback', () => { // given spyOn(documentMock, 'createElement').and.returnValue(jsDomElement); - spyOn(documentMock, 'appendChild').and.callThrough(); spyOn(jsDomElement, 'addEventListener').and.callThrough(); const params = { param1: 'value1', param2: 'value2' }; const callback = function () {}; @@ -89,7 +89,6 @@ describe('ExternalJsFileLoader', () => { it('should load script with params', () => { // given spyOn(documentMock, 'createElement').and.returnValue(jsDomElement); - spyOn(documentMock, 'appendChild').and.callThrough(); spyOn(jsDomElement, 'addEventListener').and.callThrough(); const params = { param1: 'value1', param2: 'value2 plus space' }; @@ -109,7 +108,6 @@ describe('ExternalJsFileLoader', () => { it('should load script', () => { // given spyOn(documentMock, 'createElement').and.returnValue(jsDomElement); - spyOn(documentMock, 'appendChild').and.callThrough(); spyOn(jsDomElement, 'addEventListener').and.callThrough(); // when diff --git a/projects/core/src/util/external-js-file-loader/external-js-file-loader.service.ts b/projects/core/src/util/external-js-file-loader/external-js-file-loader.service.ts index 1a5762479ea..479c857272d 100644 --- a/projects/core/src/util/external-js-file-loader/external-js-file-loader.service.ts +++ b/projects/core/src/util/external-js-file-loader/external-js-file-loader.service.ts @@ -1,14 +1,17 @@ -import { DOCUMENT } from '@angular/common'; -import { Inject, Injectable } from '@angular/core'; +import { DOCUMENT, isPlatformServer } from '@angular/common'; +import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; @Injectable({ providedIn: 'root', }) export class ExternalJsFileLoader { - constructor(@Inject(DOCUMENT) protected document: any) {} + constructor( + @Inject(DOCUMENT) protected document: any, + @Inject(PLATFORM_ID) protected platformId?: Object + ) {} /** - * Loads a javascript from an external URL + * Loads a javascript from an external URL. Loading is skipped during SSR. * @param src URL for the script to be loaded * @param params additional parameters to be attached to the given URL * @param callback a function to be invoked after the script has been loaded @@ -20,6 +23,12 @@ export class ExternalJsFileLoader { callback?: EventListener, errorCallback?: EventListener ): void { + if (this.platformId && isPlatformServer(this.platformId)) { + if (errorCallback) { + errorCallback(new Event('error')); + } + return; + } const script: HTMLScriptElement = this.document.createElement('script'); script.type = 'text/javascript'; if (params) { @@ -37,7 +46,7 @@ export class ExternalJsFileLoader { script.addEventListener('error', errorCallback); } - document.head.appendChild(script); + this.document.head.appendChild(script); } /** From 02091730e43dc60c606d1025516f44566d8df6eb Mon Sep 17 00:00:00 2001 From: Christoph Hinssen <33626130+ChristophHi@users.noreply.github.com> Date: Wed, 27 Jan 2021 18:25:37 +0100 Subject: [PATCH 28/30] feat: product configurator support (#5347) Contains a feature library that adds product configurator support to Spartacus. This includes a rule-based configurator that can be served by the SAP Variant Configurator in commerce, and a more lightweight configurator that allows to add a number of textual attributes to a product --- angular.json | 42 + ci-scripts/unit-tests-sonar.sh | 20 +- .../product-configurator/.release-it.json | 34 + feature-libs/product-configurator/README.md | 5 + feature-libs/product-configurator/_index.scss | 5 + .../product-configurator/common/_index.scss | 17 + .../common/assets}/ng-package.json | 0 .../common/assets/public_api.ts | 1 + .../translations/en/configurator-common.ts | 67 ++ .../common/assets/translations/en/index.ts | 5 + .../assets/translations/translations.ts | 8 + .../common}/common-configurator.module.ts | 4 +- .../common-configurator-components.module.ts | 15 + ...onfigurator-cart-entry-info.component.html | 21 + ...igurator-cart-entry-info.component.spec.ts | 151 +++ .../configurator-cart-entry-info.component.ts | 25 + .../configurator-cart-entry-info.module.ts | 31 + .../configurator-cart-entry-info/index.ts | 2 + ...gurator-issues-notification.component.html | 19 + ...ator-issues-notification.component.spec.ts | 176 +++ ...figurator-issues-notification.component.ts | 38 + ...configurator-issues-notification.module.ts | 29 + .../configurator-issues-notification/index.ts | 2 + .../configure-cart-entry.component.html | 38 + .../configure-cart-entry.component.spec.ts | 199 ++++ .../configure-cart-entry.component.ts | 85 ++ .../configure-cart-entry.module.ts | 20 + .../components/configure-cart-entry/index.ts | 2 + .../configure-product.component.html | 18 + .../configure-product.component.spec.ts | 148 +++ .../configure-product.component.ts | 32 + .../configure-product.module.ts | 46 + .../components/configure-product/index.ts | 2 + .../common/components/index.ts | 6 + .../service/configurator-router-data.ts | 15 + ...figurator-router-extractor.service.spec.ts | 194 +++ .../configurator-router-extractor.service.ts | 91 ++ .../common/components/service/index.ts | 2 + .../common/core/model/augmented-core.model.ts | 11 + .../core/model/common-configurator.model.ts | 76 ++ .../core/model/configurator-product-scope.ts | 3 + .../common/core/model/index.ts | 4 + .../product-configurator/common/index.ts | 5 + .../common/ng-package.json | 11 + .../occ/common-configurator-occ.module.ts | 8 + ...default-occ-configurator-product-config.ts | 22 + .../product-configurator/common/occ/index.ts | 1 + .../product-configurator/common/public_api.ts | 4 + .../common/shared/index.ts | 2 + .../common-configurator-test-utils.service.ts | 66 + .../common/shared/testing/index.ts | 1 + .../common-configurator-utils.service.spec.ts | 177 +++ .../common-configurator-utils.service.ts | 107 ++ .../common/shared/utils/index.ts | 1 + .../_configurator-issues-notification.scss | 55 + .../styles/_configure-cart-entry-info.scss | 39 + .../common/styles/_configure-cart-entry.scss | 18 + .../common/styles/_configure-product.scss | 21 + .../common/styles/_index.scss | 4 + .../jest.schematics.config.js | 29 + feature-libs/product-configurator/jest.ts | 24 + .../product-configurator/karma.conf.js | 46 + .../product-configurator/ng-package.json | 13 + .../product-configurator/package.json | 42 + .../product-configurator/public_api.ts | 4 + .../rulebased/_index.scss | 43 + ...igurator-add-to-cart-button.component.html | 15 + ...rator-add-to-cart-button.component.spec.ts | 352 ++++++ ...nfigurator-add-to-cart-button.component.ts | 218 ++++ .../configurator-add-to-cart-button.module.ts | 21 + .../components/add-to-cart-button/index.ts | 2 + ...nfigurator-attribute-footer.component.html | 7 + ...gurator-attribute-footer.component.spec.ts | 177 +++ ...configurator-attribute-footer.component.ts | 55 + .../configurator-attribute-footer.module.ts | 20 + .../components/attribute/footer/index.ts | 2 + ...nfigurator-attribute-header.component.html | 34 + ...gurator-attribute-header.component.spec.ts | 345 ++++++ ...configurator-attribute-header.component.ts | 125 ++ .../configurator-attribute-header.module.ts | 22 + .../components/attribute/header/index.ts | 2 + .../rulebased/components/attribute/index.ts | 12 + ...figurator-attribute-base.component.spec.ts | 120 ++ .../configurator-attribute-base.component.ts | 122 ++ .../components/attribute/types/base/index.ts | 1 + ...tor-attribute-checkbox-list.component.html | 24 + ...-attribute-checkbox-list.component.spec.ts | 109 ++ ...rator-attribute-checkbox-list.component.ts | 69 ++ ...igurator-attribute-checkbox-list.module.ts | 20 + .../attribute/types/checkbox-list/index.ts | 2 + ...igurator-attribute-checkbox.component.html | 32 + ...rator-attribute-checkbox.component.spec.ts | 90 ++ ...nfigurator-attribute-checkbox.component.ts | 61 + .../configurator-attribute-checkbox.module.ts | 20 + .../attribute/types/checkbox/index.ts | 2 + ...gurator-attribute-drop-down.component.html | 21 + ...ator-attribute-drop-down.component.spec.ts | 71 ++ ...figurator-attribute-drop-down.component.ts | 46 + ...configurator-attribute-drop-down.module.ts | 22 + .../attribute/types/drop-down/index.ts | 2 + ...rator-attribute-input-field.component.html | 12 + ...or-attribute-input-field.component.spec.ts | 109 ++ ...gurator-attribute-input-field.component.ts | 59 + ...nfigurator-attribute-input-field.module.ts | 20 + .../attribute/types/input-field/index.ts | 2 + ...ibute-multi-selection-image.component.html | 44 + ...te-multi-selection-image.component.spec.ts | 139 +++ ...tribute-multi-selection-image.component.ts | 73 ++ ...-attribute-multi-selection-image.module.ts | 20 + .../types/multi-selection-image/index.ts | 2 + ...tribute-numeric-input-field.component.html | 18 + ...eric-input-field.component.service.spec.ts | 131 ++ ...e-numeric-input-field.component.service.ts | 150 +++ ...bute-numeric-input-field.component.spec.ts | 178 +++ ...attribute-numeric-input-field.component.ts | 117 ++ ...or-attribute-numeric-input-field.module.ts | 20 + .../types/numeric-input-field/index.ts | 3 + ...ator-attribute-radio-button.component.html | 39 + ...r-attribute-radio-button.component.spec.ts | 96 ++ ...urator-attribute-radio-button.component.ts | 51 + ...figurator-attribute-radio-button.module.ts | 20 + .../attribute/types/radio-button/index.ts | 2 + ...gurator-attribute-read-only.component.html | 47 + ...ator-attribute-read-only.component.spec.ts | 102 ++ ...figurator-attribute-read-only.component.ts | 12 + ...configurator-attribute-read-only.module.ts | 20 + .../attribute/types/read-only/index.ts | 2 + ...bute-single-selection-image.component.html | 53 + ...e-single-selection-image.component.spec.ts | 149 +++ ...ribute-single-selection-image.component.ts | 50 + ...attribute-single-selection-image.module.ts | 20 + .../types/single-selection-image/index.ts | 2 + .../config/default-message-config.ts | 9 + .../rulebased/components/config/index.ts | 1 + .../components/config/message-config.ts | 7 + ...urator-conflict-description.component.html | 4 + ...tor-conflict-description.component.spec.ts | 52 + ...igurator-conflict-description.component.ts | 34 + ...onfigurator-conflict-description.module.ts | 12 + .../components/conflict-description/index.ts | 2 + ...gurator-conflict-suggestion.component.html | 12 + ...ator-conflict-suggestion.component.spec.ts | 51 + ...figurator-conflict-suggestion.component.ts | 37 + ...configurator-conflict-suggestion.module.ts | 12 + .../components/conflict-suggestion/index.ts | 2 + .../form/configurator-form.component.html | 101 ++ .../form/configurator-form.component.spec.ts | 405 +++++++ .../form/configurator-form.component.ts | 100 ++ .../form/configurator-form.event.ts | 5 + .../form/configurator-form.module.ts | 56 + .../rulebased/components/form/index.ts | 3 + .../configurator-group-menu.component.html | 72 ++ .../configurator-group-menu.component.spec.ts | 664 +++++++++++ .../configurator-group-menu.component.ts | 249 ++++ .../configurator-group-menu.module.ts | 22 + .../rulebased/components/group-menu/index.ts | 2 + .../configurator-group-title.component.html | 3 + ...configurator-group-title.component.spec.ts | 117 ++ .../configurator-group-title.component.ts | 42 + .../configurator-group-title.module.ts | 21 + .../rulebased/components/group-title/index.ts | 2 + .../rulebased/components/index.ts | 18 + ...igurator-overview-attribute.component.html | 4 + ...rator-overview-attribute.component.spec.ts | 40 + ...nfigurator-overview-attribute.component.ts | 10 + .../configurator-overview-attribute.module.ts | 11 + .../components/overview-attribute/index.ts | 2 + .../configurator-overview-form.component.html | 27 + ...nfigurator-overview-form.component.spec.ts | 268 +++++ .../configurator-overview-form.component.ts | 67 ++ .../configurator-overview-form.module.ts | 22 + .../components/overview-form/index.ts | 2 + ...verview-notification-banner.component.html | 27 + ...view-notification-banner.component.spec.ts | 166 +++ ...-overview-notification-banner.component.ts | 57 + ...tor-overview-notification-banner.module.ts | 28 + .../overview-notification-banner/index.ts | 2 + ...rator-previous-next-buttons.component.html | 20 + ...or-previous-next-buttons.component.spec.ts | 294 +++++ ...gurator-previous-next-buttons.component.ts | 73 ++ ...nfigurator-previous-next-buttons.module.ts | 22 + .../components/previous-next-buttons/index.ts | 2 + .../configurator-price-summary.component.html | 30 + ...nfigurator-price-summary.component.spec.ts | 107 ++ .../configurator-price-summary.component.ts | 26 + .../configurator-price-summary.module.ts | 22 + .../components/price-summary/index.ts | 2 + .../configurator-product-title.component.html | 42 + ...nfigurator-product-title.component.spec.ts | 253 ++++ .../configurator-product-title.component.ts | 54 + .../configurator-product-title.module.ts | 31 + .../components/product-title/index.ts | 2 + ...ulebased-configurator-components.module.ts | 31 + ...figurator-storefront-utils.service.spec.ts | 102 ++ .../configurator-storefront-utils.service.ts | 108 ++ .../rulebased/components/service/index.ts | 1 + .../configurator-tab-bar.component.html | 34 + .../configurator-tab-bar.component.spec.ts | 106 ++ .../tab-bar/configurator-tab-bar.component.ts | 30 + .../tab-bar/configurator-tab-bar.module.ts | 37 + .../rulebased/components/tab-bar/index.ts | 2 + ...configurator-update-message.component.html | 4 + ...figurator-update-message.component.spec.ts | 196 +++ .../configurator-update-message.component.ts | 46 + .../configurator-update-message.module.ts | 31 + .../components/update-message/index.ts | 2 + .../rulebased/core/connectors/index.ts | 2 + .../rulebased-configurator.adapter.ts | 103 ++ .../rulebased-configurator.connector.spec.ts | 255 ++++ .../rulebased-configurator.connector.ts | 108 ++ .../facade/configurator-cart.service.spec.ts | 365 ++++++ .../core/facade/configurator-cart.service.ts | 212 ++++ .../configurator-commons.service.spec.ts | 516 ++++++++ .../facade/configurator-commons.service.ts | 246 ++++ .../configurator-group-status.service.spec.ts | 116 ++ .../configurator-group-status.service.ts | 141 +++ .../configurator-groups.service.spec.ts | 365 ++++++ .../facade/configurator-groups.service.ts | 337 ++++++ .../rulebased/core/facade/index.ts | 5 + .../utils/configurator-utils.service.spec.ts | 315 +++++ .../utils/configurator-utils.service.ts | 140 +++ .../rulebased/core/facade/utils/index.ts | 1 + .../rulebased/core/index.ts | 5 + .../core/model/configurator.model.ts | 179 +++ .../rulebased/core/model/index.ts | 1 + .../rulebased-configurator-core.module.ts | 13 + .../actions/configurator-cart.action.spec.ts | 94 ++ .../state/actions/configurator-cart.action.ts | 118 ++ .../actions/configurator-group.actions.ts | 2 + .../state/actions/configurator.action.spec.ts | 166 +++ .../core/state/actions/configurator.action.ts | 281 +++++ .../rulebased/core/state/actions/index.ts | 2 + .../core/state/configurator-state.ts | 15 + .../effects/configurator-basic.effect.spec.ts | 589 +++++++++ .../effects/configurator-basic.effect.ts | 371 ++++++ .../effects/configurator-cart.effect.spec.ts | 404 +++++++ .../state/effects/configurator-cart.effect.ts | 203 ++++ ...nfigurator-place-order-hook.effect.spec.ts | 149 +++ .../configurator-place-order-hook.effect.ts | 56 + .../rulebased/core/state/effects/index.ts | 9 + .../rulebased/core/state/index.ts | 3 + .../reducers/configurator.reducer.spec.ts | 384 ++++++ .../state/reducers/configurator.reducer.ts | 183 +++ .../rulebased/core/state/reducers/index.ts | 25 + .../rulebased-configurator-state.module.ts | 23 + .../selectors/configurator-group.selectors.ts | 1 + .../selectors/configurator.selector.spec.ts | 199 ++++ .../state/selectors/configurator.selector.ts | 96 ++ .../rulebased/core/state/selectors/index.ts | 2 + .../product-configurator/rulebased/index.ts | 5 + .../rulebased/ng-package.json | 13 + .../rulebased/occ/variant/converters/index.ts | 6 + ...tor-variant-add-to-cart-serializer.spec.ts | 55 + ...igurator-variant-add-to-cart-serializer.ts | 30 + ...cc-configurator-variant-normalizer.spec.ts | 702 +++++++++++ .../occ-configurator-variant-normalizer.ts | 328 +++++ ...urator-variant-overview-normalizer.spec.ts | 213 ++++ ...onfigurator-variant-overview-normalizer.ts | 79 ++ ...r-variant-price-summary-normalizer.spec.ts | 56 + ...urator-variant-price-summary-normalizer.ts | 20 + ...cc-configurator-variant-serializer.spec.ts | 315 +++++ .../occ-configurator-variant-serializer.ts | 149 +++ ...riant-update-cart-entry-serializer.spec.ts | 59 + ...or-variant-update-cart-entry-serializer.ts | 29 + ...default-occ-configurator-variant-config.ts | 36 + .../rulebased/occ/variant/index.ts | 4 + .../variant-configurator-occ.adapter.spec.ts | 401 +++++++ .../variant-configurator-occ.adapter.ts | 254 ++++ .../variant-configurator-occ.converters.ts | 34 + .../variant-configurator-occ.models.ts | 173 +++ .../variant-configurator-occ.module.ts | 65 + .../rulebased/public_api.ts | 5 + .../root/default-rulebased-routing-config.ts | 17 + .../rulebased/root/index.ts | 4 + .../rulebased/root/ng-package.json | 11 + .../rulebased/root/public_api.ts | 5 + ...ebased-configurator-root-feature.module.ts | 32 + .../rulebased-configurator-root.module.ts | 29 + .../rulebased-configurator-routing.module.ts | 22 + .../rulebased/root/variant/index.ts | 2 + ...variant-configurator-interactive.module.ts | 78 ++ .../variant-configurator-overview.module.ts | 65 + .../rulebased-configurator.module.ts | 13 + ...nfigurator-component-test-utils.service.ts | 69 ++ .../shared/testing/configurator-test-data.ts | 511 ++++++++ .../rulebased/shared/testing/index.ts | 1 + .../_configurator-add-to-cart-button.scss | 11 + ..._configurator-attribute-checkbox-list.scss | 3 + .../_configurator-attribute-checkbox.scss | 3 + .../_configurator-attribute-drop-down.scss | 4 + .../_configurator-attribute-footer.scss | 8 + .../_configurator-attribute-header.scss | 40 + .../_configurator-attribute-input-field.scss | 4 + ...rator-attribute-multi-selection-image.scss | 3 + ...gurator-attribute-numeric-input-field.scss | 4 + .../_configurator-attribute-radio-button.scss | 9 + .../_configurator-attribute-read-only.scss | 8 + ...ator-attribute-single-selection-image.scss | 3 + .../_configurator-conflict-description.scss | 20 + .../_configurator-conflict-suggestion.scss | 30 + .../rulebased/styles/_configurator-form.scss | 19 + .../styles/_configurator-group-menu.scss | 168 +++ .../styles/_configurator-group-title.scss | 23 + .../_configurator-overview-attribute.scss | 23 + .../styles/_configurator-overview-form.scss | 45 + ...igurator-overview-notification-banner.scss | 54 + .../_configurator-previous-next-buttons.scss | 40 + .../styles/_configurator-price-summary.scss | 37 + .../styles/_configurator-product-title.scss | 108 ++ .../styles/_configurator-tab-bar.scss | 80 ++ .../styles/_configurator-update-message.scss | 48 + .../rulebased/styles/_index.scss | 27 + ...onfigurator-attribute-selection-image.scss | 70 ++ .../mixins/_configurator-attribute-type.scss | 10 + .../_configurator-footer-container-item.scss | 9 + .../_configurator-footer-container.scss | 18 + .../mixins/_configurator-form-group.scss | 13 + .../_configurator-required-error-msg.scss | 6 + .../styles/mixins/_configurator-template.scss | 9 + .../rulebased/styles/mixins/_mixins.scss | 7 + .../_configurator-variant-overview-page.scss | 3 + .../pages/_configurator-variant-page.scss | 28 + .../rulebased/styles/pages/_index.scss | 2 + .../schematics/.gitignore | 18 + .../add-product-configurator/index.ts | 127 ++ .../add-product-configurator/index_spec.ts | 254 ++++ .../add-product-configurator/schema.json | 21 + .../schematics/collection.json | 13 + feature-libs/product-configurator/test.ts | 35 + .../textfield/_index.scss | 25 + ...extfield-add-to-cart-button.component.html | 7 + ...field-add-to-cart-button.component.spec.ts | 157 +++ ...-textfield-add-to-cart-button.component.ts | 50 + ...configurator-textfield-form.component.html | 14 + ...figurator-textfield-form.component.spec.ts | 139 +++ .../configurator-textfield-form.component.ts | 47 + .../textfield/components/index.ts | 4 + ...rator-textfield-input-field.component.html | 14 + ...or-textfield-input-field.component.spec.ts | 57 + ...gurator-textfield-input-field.component.ts | 68 ++ ...extfield-configurator-components.module.ts | 51 + .../configurator-textfield.adapter.ts | 50 + .../configurator-textfield.connector.spec.ts | 131 ++ .../configurator-textfield.connector.ts | 55 + .../textfield/core/connectors/converters.ts | 15 + .../textfield/core/connectors/index.ts | 3 + .../configurator-textfield.service.spec.ts | 294 +++++ .../facade/configurator-textfield.service.ts | 201 ++++ .../textfield/core/facade/index.ts | 1 + .../textfield}/core/index.ts | 0 .../model/configurator-textfield.model.ts | 47 + .../configurator-textfield-group.actions.ts | 1 + .../configurator-textfield.action.spec.ts | 48 + .../actions/configurator-textfield.action.ts | 127 ++ .../textfield/core/state/actions/index.ts | 2 + .../state/configuration-textfield-state.ts | 14 + .../configurator-textfield-store.module.ts | 26 + .../configurator-textfield.effect.spec.ts | 274 +++++ .../effects/configurator-textfield.effect.ts | 142 +++ .../textfield/core/state/effects/index.ts | 7 + .../textfield/core/state/index.ts | 3 + .../configurator-textfield.reducer.spec.ts | 69 ++ .../configurator-textfield.reducer.ts | 28 + .../textfield/core/state/reducers/index.ts | 31 + .../configurator-textfield-group.selectors.ts | 1 + .../configurator-textfield.selector.spec.ts | 67 ++ .../configurator-textfield.selector.ts | 26 + .../textfield/core/state/selectors/index.ts | 2 + .../textfield-configurator-core.module.ts | 13 + .../textfield}/index.ts | 1 + .../textfield/ng-package.json | 14 + .../textfield/occ/converters/index.ts | 2 + ...r-textfield-add-to-cart-serializer.spec.ts | 64 + ...urator-textfield-add-to-cart-serializer.ts | 54 + ...-configurator-textfield-normalizer.spec.ts | 54 + .../occ-configurator-textfield-normalizer.ts | 31 + ...field-update-cart-entry-serializer.spec.ts | 59 + ...-textfield-update-cart-entry-serializer.ts | 52 + ...fault-occ-configurator-textfield-config.ts | 23 + .../textfield/occ/index.ts | 3 + ...occ-configurator-textfield.adapter.spec.ts | 199 ++++ .../occ/occ-configurator-textfield.adapter.ts | 119 ++ .../occ/occ-configurator-textfield.models.ts | 34 + .../occ/textfield-configurator-occ.module.ts | 46 + .../textfield/public_api.ts | 2 +- .../root/default-textfield-routing-config.ts | 11 + .../textfield/root/index.ts | 3 + .../textfield/root/ng-package.json | 11 + .../textfield/root/public_api.ts | 5 + ...tfield-configurator-root-feature.module.ts | 20 + .../textfield-configurator-root.module.ts | 50 + .../textfield-configurator-routing.module.ts | 22 + ...igurator-textfield-add-to-cart-button.scss | 13 + .../styles/_configurator-textfield-form.scss | 9 + .../_configurator-textfield-input-field.scss | 29 + .../textfield/styles/_index.scss | 4 + .../pages/_configuration-textfield-page.scss | 16 + .../textfield/styles/pages/_index.scss | 1 + .../textfield-configurator.module.ts | 6 + .../product-configurator/tsconfig.lib.json | 28 + .../tsconfig.lib.prod.json | 6 + .../tsconfig.schematics.json | 28 + .../product-configurator/tsconfig.spec.json | 10 + feature-libs/product-configurator/tslint.json | 7 + feature-libs/product/_index.scss | 0 .../configurators/common/public_api.ts | 5 - .../common-configurator-components.module.ts | 4 - .../common/src/components/index.ts | 1 - .../core/common-configurator-core.module.ts | 4 - .../configurators/common/src/core/index.ts | 1 - .../product/configurators/common/src/index.ts | 3 - .../product/configurators/cpq/ng-package.json | 6 - .../product/configurators/cpq/public_api.ts | 5 - .../cpq-configurator-components.module.ts | 4 - .../configurators/cpq/src/components/index.ts | 1 - .../src/core/cpq-configurator-core.module.ts | 4 - .../configurators/cpq/src/core/index.ts | 1 - .../cpq/src/cpq-configurator.module.ts | 8 - .../product/configurators/cpq/src/index.ts | 3 - .../product/configurators/ng-package.json | 6 - .../product/configurators/public_api.ts | 8 - .../configurators/textfield/ng-package.json | 6 - .../textfield/src/components/index.ts | 1 - ...extfield-configurator-components.module.ts | 4 - .../textfield-configurator-core.module.ts | 4 - .../configurators/variant/ng-package.json | 6 - .../configurators/variant/public_api.ts | 5 - .../variant/src/components/index.ts | 1 - .../variant-configurator-components.module.ts | 4 - .../configurators/variant/src/core/index.ts | 1 - .../core/variant-configurator-core.module.ts | 4 - .../configurators/variant/src/index.ts | 3 - .../src/variant-configurator.module.ts | 8 - feature-libs/product/index.spec.ts | 5 + feature-libs/product/package.json | 4 +- feature-libs/product/public_api.ts | 3 +- feature-libs/product/tsconfig.spec.json | 3 +- package.json | 11 +- .../cart/facade/active-cart.service.spec.ts | 21 + .../src/cart/facade/active-cart.service.ts | 2 +- .../cart/facade/multi-cart.service.spec.ts | 24 + .../checkout/facade/checkout.service.spec.ts | 39 +- .../src/checkout/facade/checkout.service.ts | 9 + .../store/selectors/checkout.selectors.ts | 7 + projects/core/src/model/order.model.ts | 1 + .../converters/occ-order-normalizer.ts | 9 +- .../product/default-occ-product-config.ts | 4 +- .../src/occ/occ-models/occ-endpoints.model.ts | 68 ++ .../core/src/occ/occ-models/occ.models.ts | 58 + .../accessibility/tabbing-order.config.ts | 64 + .../helpers/product-configuration-overview.ts | 127 ++ .../cypress/helpers/product-configuration.ts | 1058 +++++++++++++++++ .../cypress/helpers/product-search.ts | 4 + .../helpers/textfield-configuration.ts | 198 +++ ...ct-configuration-tabbing.flaky-e2e-spec.ts | 116 ++ ...uct-configuration-mobile.flaky-e2e-spec.ts | 39 + ...oduct-configuration-cart.flaky-e2e-spec.ts | 189 +++ ...onfiguration-interactive.flaky-e2e-spec.ts | 303 +++++ ...roduct-configuration-textfield.e2e-spec.ts | 76 ++ .../storefrontapp-e2e-cypress/package.json | 1 + projects/storefrontapp/src/app/app.module.ts | 35 +- .../productconfig/productconfig.feature.ts | 31 + .../src/styles/lib-product-configurator.scss | 1 + projects/storefrontapp/tsconfig.app.prod.json | 28 +- projects/storefrontapp/tsconfig.server.json | 30 +- .../storefrontapp/tsconfig.server.prod.json | 30 +- .../cart-item/cart-item-component.model.ts | 26 + .../cart-item/cart-item.component.html | 13 +- .../cart-item/cart-item.component.spec.ts | 39 +- .../cart-item/cart-item.component.ts | 47 +- .../cart/cart-shared/cart-item/index.ts | 2 + .../cart/cart-shared/cart-shared.module.ts | 4 + .../cms-components/cart/cart-shared/index.ts | 4 +- .../misc/icon/fontawesome-icon.config.ts | 1 + .../cms-components/misc/icon/icon.model.ts | 1 + .../product/current-product.service.ts | 9 +- .../src/cms-components/product/index.ts | 3 +- .../product/product-list-item-context.spec.ts | 51 + .../product/product-list-item-context.ts | 17 + .../product-scroll.component.spec.ts | 3 +- .../product-grid-item.component.html | 3 + .../product-grid-item.component.spec.ts | 41 +- .../product-grid-item.component.ts | 26 +- .../product-list-item.component.html | 2 + .../product-list-item.component.spec.ts | 41 +- .../product-list-item.component.ts | 26 +- .../product-list/product-list.module.ts | 2 + .../product/product-outlets.model.ts | 4 + .../page-templates/_product-detail.scss | 7 + scripts/changelog.ts | 10 +- scripts/install/README.md | 1 + scripts/install/config.default.sh | 3 + scripts/install/run.sh | 12 + scripts/templates/changelog.ejs | 1 + sonar-project.properties | 8 +- tsconfig.compodoc.json | 44 +- tsconfig.json | 36 +- 497 files changed, 29476 insertions(+), 239 deletions(-) create mode 100644 feature-libs/product-configurator/.release-it.json create mode 100644 feature-libs/product-configurator/README.md create mode 100644 feature-libs/product-configurator/_index.scss create mode 100644 feature-libs/product-configurator/common/_index.scss rename feature-libs/{product/configurators/common => product-configurator/common/assets}/ng-package.json (100%) create mode 100644 feature-libs/product-configurator/common/assets/public_api.ts create mode 100644 feature-libs/product-configurator/common/assets/translations/en/configurator-common.ts create mode 100644 feature-libs/product-configurator/common/assets/translations/en/index.ts create mode 100644 feature-libs/product-configurator/common/assets/translations/translations.ts rename feature-libs/{product/configurators/common/src => product-configurator/common}/common-configurator.module.ts (55%) create mode 100644 feature-libs/product-configurator/common/components/common-configurator-components.module.ts create mode 100644 feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.component.html create mode 100644 feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.component.spec.ts create mode 100644 feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.component.ts create mode 100644 feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.module.ts create mode 100644 feature-libs/product-configurator/common/components/configurator-cart-entry-info/index.ts create mode 100644 feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.component.html create mode 100644 feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.component.spec.ts create mode 100644 feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.component.ts create mode 100644 feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.module.ts create mode 100644 feature-libs/product-configurator/common/components/configurator-issues-notification/index.ts create mode 100644 feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.component.html create mode 100644 feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.component.spec.ts create mode 100644 feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.component.ts create mode 100644 feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.module.ts create mode 100644 feature-libs/product-configurator/common/components/configure-cart-entry/index.ts create mode 100644 feature-libs/product-configurator/common/components/configure-product/configure-product.component.html create mode 100644 feature-libs/product-configurator/common/components/configure-product/configure-product.component.spec.ts create mode 100644 feature-libs/product-configurator/common/components/configure-product/configure-product.component.ts create mode 100644 feature-libs/product-configurator/common/components/configure-product/configure-product.module.ts create mode 100644 feature-libs/product-configurator/common/components/configure-product/index.ts create mode 100644 feature-libs/product-configurator/common/components/index.ts create mode 100644 feature-libs/product-configurator/common/components/service/configurator-router-data.ts create mode 100644 feature-libs/product-configurator/common/components/service/configurator-router-extractor.service.spec.ts create mode 100644 feature-libs/product-configurator/common/components/service/configurator-router-extractor.service.ts create mode 100644 feature-libs/product-configurator/common/components/service/index.ts create mode 100644 feature-libs/product-configurator/common/core/model/augmented-core.model.ts create mode 100644 feature-libs/product-configurator/common/core/model/common-configurator.model.ts create mode 100644 feature-libs/product-configurator/common/core/model/configurator-product-scope.ts create mode 100644 feature-libs/product-configurator/common/core/model/index.ts create mode 100644 feature-libs/product-configurator/common/index.ts create mode 100644 feature-libs/product-configurator/common/ng-package.json create mode 100644 feature-libs/product-configurator/common/occ/common-configurator-occ.module.ts create mode 100644 feature-libs/product-configurator/common/occ/default-occ-configurator-product-config.ts create mode 100644 feature-libs/product-configurator/common/occ/index.ts create mode 100644 feature-libs/product-configurator/common/public_api.ts create mode 100644 feature-libs/product-configurator/common/shared/index.ts create mode 100644 feature-libs/product-configurator/common/shared/testing/common-configurator-test-utils.service.ts create mode 100644 feature-libs/product-configurator/common/shared/testing/index.ts create mode 100644 feature-libs/product-configurator/common/shared/utils/common-configurator-utils.service.spec.ts create mode 100644 feature-libs/product-configurator/common/shared/utils/common-configurator-utils.service.ts create mode 100644 feature-libs/product-configurator/common/shared/utils/index.ts create mode 100644 feature-libs/product-configurator/common/styles/_configurator-issues-notification.scss create mode 100644 feature-libs/product-configurator/common/styles/_configure-cart-entry-info.scss create mode 100644 feature-libs/product-configurator/common/styles/_configure-cart-entry.scss create mode 100644 feature-libs/product-configurator/common/styles/_configure-product.scss create mode 100644 feature-libs/product-configurator/common/styles/_index.scss create mode 100644 feature-libs/product-configurator/jest.schematics.config.js create mode 100644 feature-libs/product-configurator/jest.ts create mode 100644 feature-libs/product-configurator/karma.conf.js create mode 100644 feature-libs/product-configurator/ng-package.json create mode 100644 feature-libs/product-configurator/package.json create mode 100644 feature-libs/product-configurator/public_api.ts create mode 100644 feature-libs/product-configurator/rulebased/_index.scss create mode 100644 feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/add-to-cart-button/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/footer/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/header/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/base/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/input-field/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.service.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.service.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/read-only/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/config/default-message-config.ts create mode 100644 feature-libs/product-configurator/rulebased/components/config/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/config/message-config.ts create mode 100644 feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/conflict-description/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/conflict-suggestion/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/form/configurator-form.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/form/configurator-form.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/form/configurator-form.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/form/configurator-form.event.ts create mode 100644 feature-libs/product-configurator/rulebased/components/form/configurator-form.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/form/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/group-menu/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/group-title/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/overview-attribute/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/overview-form/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/overview-notification-banner/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/previous-next-buttons/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/price-summary/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/product-title/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/rulebased-configurator-components.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/service/configurator-storefront-utils.service.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/service/configurator-storefront-utils.service.ts create mode 100644 feature-libs/product-configurator/rulebased/components/service/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/tab-bar/index.ts create mode 100644 feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.component.html create mode 100644 feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.component.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.component.ts create mode 100644 feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.module.ts create mode 100644 feature-libs/product-configurator/rulebased/components/update-message/index.ts create mode 100644 feature-libs/product-configurator/rulebased/core/connectors/index.ts create mode 100644 feature-libs/product-configurator/rulebased/core/connectors/rulebased-configurator.adapter.ts create mode 100644 feature-libs/product-configurator/rulebased/core/connectors/rulebased-configurator.connector.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/connectors/rulebased-configurator.connector.ts create mode 100644 feature-libs/product-configurator/rulebased/core/facade/configurator-cart.service.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/facade/configurator-cart.service.ts create mode 100644 feature-libs/product-configurator/rulebased/core/facade/configurator-commons.service.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/facade/configurator-commons.service.ts create mode 100644 feature-libs/product-configurator/rulebased/core/facade/configurator-group-status.service.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/facade/configurator-group-status.service.ts create mode 100644 feature-libs/product-configurator/rulebased/core/facade/configurator-groups.service.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/facade/configurator-groups.service.ts create mode 100644 feature-libs/product-configurator/rulebased/core/facade/index.ts create mode 100644 feature-libs/product-configurator/rulebased/core/facade/utils/configurator-utils.service.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/facade/utils/configurator-utils.service.ts create mode 100644 feature-libs/product-configurator/rulebased/core/facade/utils/index.ts create mode 100644 feature-libs/product-configurator/rulebased/core/index.ts create mode 100644 feature-libs/product-configurator/rulebased/core/model/configurator.model.ts create mode 100644 feature-libs/product-configurator/rulebased/core/model/index.ts create mode 100644 feature-libs/product-configurator/rulebased/core/rulebased-configurator-core.module.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/actions/configurator-cart.action.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/actions/configurator-cart.action.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/actions/configurator-group.actions.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/actions/configurator.action.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/actions/configurator.action.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/actions/index.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/configurator-state.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/effects/configurator-cart.effect.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/effects/configurator-cart.effect.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/effects/configurator-place-order-hook.effect.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/effects/configurator-place-order-hook.effect.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/effects/index.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/index.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/reducers/index.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/rulebased-configurator-state.module.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/selectors/configurator-group.selectors.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/selectors/configurator.selector.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/selectors/configurator.selector.ts create mode 100644 feature-libs/product-configurator/rulebased/core/state/selectors/index.ts create mode 100644 feature-libs/product-configurator/rulebased/index.ts create mode 100644 feature-libs/product-configurator/rulebased/ng-package.json create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/index.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-add-to-cart-serializer.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-add-to-cart-serializer.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-normalizer.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-normalizer.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-overview-normalizer.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-overview-normalizer.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-price-summary-normalizer.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-price-summary-normalizer.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-serializer.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-serializer.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-update-cart-entry-serializer.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-update-cart-entry-serializer.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/default-occ-configurator-variant-config.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/index.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.adapter.spec.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.adapter.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.converters.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.models.ts create mode 100644 feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.module.ts create mode 100644 feature-libs/product-configurator/rulebased/public_api.ts create mode 100644 feature-libs/product-configurator/rulebased/root/default-rulebased-routing-config.ts create mode 100644 feature-libs/product-configurator/rulebased/root/index.ts create mode 100644 feature-libs/product-configurator/rulebased/root/ng-package.json create mode 100644 feature-libs/product-configurator/rulebased/root/public_api.ts create mode 100644 feature-libs/product-configurator/rulebased/root/rulebased-configurator-root-feature.module.ts create mode 100644 feature-libs/product-configurator/rulebased/root/rulebased-configurator-root.module.ts create mode 100644 feature-libs/product-configurator/rulebased/root/rulebased-configurator-routing.module.ts create mode 100644 feature-libs/product-configurator/rulebased/root/variant/index.ts create mode 100644 feature-libs/product-configurator/rulebased/root/variant/variant-configurator-interactive.module.ts create mode 100644 feature-libs/product-configurator/rulebased/root/variant/variant-configurator-overview.module.ts create mode 100644 feature-libs/product-configurator/rulebased/rulebased-configurator.module.ts create mode 100644 feature-libs/product-configurator/rulebased/shared/testing/configurator-component-test-utils.service.ts create mode 100644 feature-libs/product-configurator/rulebased/shared/testing/configurator-test-data.ts create mode 100644 feature-libs/product-configurator/rulebased/shared/testing/index.ts create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-add-to-cart-button.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-attribute-checkbox-list.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-attribute-checkbox.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-attribute-drop-down.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-attribute-footer.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-attribute-header.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-attribute-input-field.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-attribute-multi-selection-image.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-attribute-numeric-input-field.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-attribute-radio-button.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-attribute-read-only.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-attribute-single-selection-image.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-conflict-description.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-conflict-suggestion.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-form.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-group-menu.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-group-title.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-overview-attribute.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-overview-form.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-overview-notification-banner.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-previous-next-buttons.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-price-summary.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-product-title.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-tab-bar.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_configurator-update-message.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/_index.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/mixins/_configurator-attribute-selection-image.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/mixins/_configurator-attribute-type.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/mixins/_configurator-footer-container-item.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/mixins/_configurator-footer-container.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/mixins/_configurator-form-group.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/mixins/_configurator-required-error-msg.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/mixins/_configurator-template.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/mixins/_mixins.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/pages/_configurator-variant-overview-page.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/pages/_configurator-variant-page.scss create mode 100644 feature-libs/product-configurator/rulebased/styles/pages/_index.scss create mode 100644 feature-libs/product-configurator/schematics/.gitignore create mode 100644 feature-libs/product-configurator/schematics/add-product-configurator/index.ts create mode 100644 feature-libs/product-configurator/schematics/add-product-configurator/index_spec.ts create mode 100644 feature-libs/product-configurator/schematics/add-product-configurator/schema.json create mode 100644 feature-libs/product-configurator/schematics/collection.json create mode 100644 feature-libs/product-configurator/test.ts create mode 100644 feature-libs/product-configurator/textfield/_index.scss create mode 100644 feature-libs/product-configurator/textfield/components/add-to-cart-button/configurator-textfield-add-to-cart-button.component.html create mode 100644 feature-libs/product-configurator/textfield/components/add-to-cart-button/configurator-textfield-add-to-cart-button.component.spec.ts create mode 100644 feature-libs/product-configurator/textfield/components/add-to-cart-button/configurator-textfield-add-to-cart-button.component.ts create mode 100644 feature-libs/product-configurator/textfield/components/form/configurator-textfield-form.component.html create mode 100644 feature-libs/product-configurator/textfield/components/form/configurator-textfield-form.component.spec.ts create mode 100644 feature-libs/product-configurator/textfield/components/form/configurator-textfield-form.component.ts create mode 100644 feature-libs/product-configurator/textfield/components/index.ts create mode 100644 feature-libs/product-configurator/textfield/components/input-field/configurator-textfield-input-field.component.html create mode 100644 feature-libs/product-configurator/textfield/components/input-field/configurator-textfield-input-field.component.spec.ts create mode 100644 feature-libs/product-configurator/textfield/components/input-field/configurator-textfield-input-field.component.ts create mode 100644 feature-libs/product-configurator/textfield/components/textfield-configurator-components.module.ts create mode 100644 feature-libs/product-configurator/textfield/core/connectors/configurator-textfield.adapter.ts create mode 100644 feature-libs/product-configurator/textfield/core/connectors/configurator-textfield.connector.spec.ts create mode 100644 feature-libs/product-configurator/textfield/core/connectors/configurator-textfield.connector.ts create mode 100644 feature-libs/product-configurator/textfield/core/connectors/converters.ts create mode 100644 feature-libs/product-configurator/textfield/core/connectors/index.ts create mode 100644 feature-libs/product-configurator/textfield/core/facade/configurator-textfield.service.spec.ts create mode 100644 feature-libs/product-configurator/textfield/core/facade/configurator-textfield.service.ts create mode 100644 feature-libs/product-configurator/textfield/core/facade/index.ts rename feature-libs/{product/configurators/textfield/src => product-configurator/textfield}/core/index.ts (100%) create mode 100644 feature-libs/product-configurator/textfield/core/model/configurator-textfield.model.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/actions/configurator-textfield-group.actions.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/actions/configurator-textfield.action.spec.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/actions/configurator-textfield.action.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/actions/index.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/configuration-textfield-state.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/configurator-textfield-store.module.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/effects/configurator-textfield.effect.spec.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/effects/configurator-textfield.effect.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/effects/index.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/index.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/reducers/configurator-textfield.reducer.spec.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/reducers/configurator-textfield.reducer.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/reducers/index.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/selectors/configurator-textfield-group.selectors.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/selectors/configurator-textfield.selector.spec.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/selectors/configurator-textfield.selector.ts create mode 100644 feature-libs/product-configurator/textfield/core/state/selectors/index.ts create mode 100644 feature-libs/product-configurator/textfield/core/textfield-configurator-core.module.ts rename feature-libs/{product/configurators/textfield/src => product-configurator/textfield}/index.ts (79%) create mode 100644 feature-libs/product-configurator/textfield/ng-package.json create mode 100644 feature-libs/product-configurator/textfield/occ/converters/index.ts create mode 100644 feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-add-to-cart-serializer.spec.ts create mode 100644 feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-add-to-cart-serializer.ts create mode 100644 feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-normalizer.spec.ts create mode 100644 feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-normalizer.ts create mode 100644 feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-update-cart-entry-serializer.spec.ts create mode 100644 feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-update-cart-entry-serializer.ts create mode 100644 feature-libs/product-configurator/textfield/occ/default-occ-configurator-textfield-config.ts create mode 100644 feature-libs/product-configurator/textfield/occ/index.ts create mode 100644 feature-libs/product-configurator/textfield/occ/occ-configurator-textfield.adapter.spec.ts create mode 100644 feature-libs/product-configurator/textfield/occ/occ-configurator-textfield.adapter.ts create mode 100644 feature-libs/product-configurator/textfield/occ/occ-configurator-textfield.models.ts create mode 100644 feature-libs/product-configurator/textfield/occ/textfield-configurator-occ.module.ts rename feature-libs/{product/configurators => product-configurator}/textfield/public_api.ts (65%) create mode 100644 feature-libs/product-configurator/textfield/root/default-textfield-routing-config.ts create mode 100644 feature-libs/product-configurator/textfield/root/index.ts create mode 100644 feature-libs/product-configurator/textfield/root/ng-package.json create mode 100644 feature-libs/product-configurator/textfield/root/public_api.ts create mode 100644 feature-libs/product-configurator/textfield/root/textfield-configurator-root-feature.module.ts create mode 100644 feature-libs/product-configurator/textfield/root/textfield-configurator-root.module.ts create mode 100644 feature-libs/product-configurator/textfield/root/textfield-configurator-routing.module.ts create mode 100644 feature-libs/product-configurator/textfield/styles/_configurator-textfield-add-to-cart-button.scss create mode 100644 feature-libs/product-configurator/textfield/styles/_configurator-textfield-form.scss create mode 100644 feature-libs/product-configurator/textfield/styles/_configurator-textfield-input-field.scss create mode 100644 feature-libs/product-configurator/textfield/styles/_index.scss create mode 100644 feature-libs/product-configurator/textfield/styles/pages/_configuration-textfield-page.scss create mode 100644 feature-libs/product-configurator/textfield/styles/pages/_index.scss rename feature-libs/{product/configurators/textfield/src => product-configurator/textfield}/textfield-configurator.module.ts (52%) create mode 100644 feature-libs/product-configurator/tsconfig.lib.json create mode 100644 feature-libs/product-configurator/tsconfig.lib.prod.json create mode 100644 feature-libs/product-configurator/tsconfig.schematics.json create mode 100644 feature-libs/product-configurator/tsconfig.spec.json create mode 100644 feature-libs/product-configurator/tslint.json create mode 100644 feature-libs/product/_index.scss delete mode 100644 feature-libs/product/configurators/common/public_api.ts delete mode 100644 feature-libs/product/configurators/common/src/components/common-configurator-components.module.ts delete mode 100644 feature-libs/product/configurators/common/src/components/index.ts delete mode 100644 feature-libs/product/configurators/common/src/core/common-configurator-core.module.ts delete mode 100644 feature-libs/product/configurators/common/src/core/index.ts delete mode 100644 feature-libs/product/configurators/common/src/index.ts delete mode 100644 feature-libs/product/configurators/cpq/ng-package.json delete mode 100644 feature-libs/product/configurators/cpq/public_api.ts delete mode 100644 feature-libs/product/configurators/cpq/src/components/cpq-configurator-components.module.ts delete mode 100644 feature-libs/product/configurators/cpq/src/components/index.ts delete mode 100644 feature-libs/product/configurators/cpq/src/core/cpq-configurator-core.module.ts delete mode 100644 feature-libs/product/configurators/cpq/src/core/index.ts delete mode 100644 feature-libs/product/configurators/cpq/src/cpq-configurator.module.ts delete mode 100644 feature-libs/product/configurators/cpq/src/index.ts delete mode 100644 feature-libs/product/configurators/ng-package.json delete mode 100644 feature-libs/product/configurators/public_api.ts delete mode 100644 feature-libs/product/configurators/textfield/ng-package.json delete mode 100644 feature-libs/product/configurators/textfield/src/components/index.ts delete mode 100644 feature-libs/product/configurators/textfield/src/components/textfield-configurator-components.module.ts delete mode 100644 feature-libs/product/configurators/textfield/src/core/textfield-configurator-core.module.ts delete mode 100644 feature-libs/product/configurators/variant/ng-package.json delete mode 100644 feature-libs/product/configurators/variant/public_api.ts delete mode 100644 feature-libs/product/configurators/variant/src/components/index.ts delete mode 100644 feature-libs/product/configurators/variant/src/components/variant-configurator-components.module.ts delete mode 100644 feature-libs/product/configurators/variant/src/core/index.ts delete mode 100644 feature-libs/product/configurators/variant/src/core/variant-configurator-core.module.ts delete mode 100644 feature-libs/product/configurators/variant/src/index.ts delete mode 100644 feature-libs/product/configurators/variant/src/variant-configurator.module.ts create mode 100644 feature-libs/product/index.spec.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/helpers/product-configuration-overview.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/helpers/product-configuration.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/helpers/textfield-configuration.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/integration/accessibility/product-configuration-tabbing.flaky-e2e-spec.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/integration/mobile/product-configuration-mobile.flaky-e2e-spec.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-configuration-cart.flaky-e2e-spec.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-configuration-interactive.flaky-e2e-spec.ts create mode 100644 projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-configuration-textfield.e2e-spec.ts create mode 100644 projects/storefrontapp/src/environments/productconfig/productconfig.feature.ts create mode 100644 projects/storefrontapp/src/styles/lib-product-configurator.scss create mode 100644 projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item-component.model.ts create mode 100644 projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/index.ts create mode 100644 projects/storefrontlib/src/cms-components/product/product-list-item-context.spec.ts create mode 100644 projects/storefrontlib/src/cms-components/product/product-list-item-context.ts diff --git a/angular.json b/angular.json index 59fdb584089..fc42002c0fa 100644 --- a/angular.json +++ b/angular.json @@ -38,6 +38,10 @@ "input": "projects/storefrontapp/src/styles/lib-organization.scss", "bundleName": "organization" }, + { + "input": "projects/storefrontapp/src/styles/lib-product-configurator.scss", + "bundleName": "product-configurator" + }, { "input": "projects/storefrontapp/src/styles/lib-storefinder.scss", "bundleName": "storefinder" @@ -491,6 +495,44 @@ } } }, + "product-configurator": { + "projectType": "library", + "root": "feature-libs/product-configurator", + "sourceRoot": "feature-libs/product-configurator", + "prefix": "cx", + "architect": { + "build": { + "builder": "@angular-devkit/build-ng-packagr:build", + "options": { + "tsConfig": "feature-libs/product-configurator/tsconfig.lib.json", + "project": "feature-libs/product-configurator/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "feature-libs/product-configurator/tsconfig.lib.prod.json" + } + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "feature-libs/product-configurator/test.ts", + "tsConfig": "feature-libs/product-configurator/tsconfig.spec.json", + "karmaConfig": "feature-libs/product-configurator/karma.conf.js" + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "feature-libs/product-configurator/tsconfig.lib.json", + "feature-libs/product-configurator/tsconfig.spec.json" + ], + "exclude": ["**/node_modules/**"] + } + } + } + }, "cdc": { "projectType": "library", "root": "integration-libs/cdc", diff --git a/ci-scripts/unit-tests-sonar.sh b/ci-scripts/unit-tests-sonar.sh index 6ccf6c95f76..60d8982c565 100755 --- a/ci-scripts/unit-tests-sonar.sh +++ b/ci-scripts/unit-tests-sonar.sh @@ -13,7 +13,7 @@ if [[ -n "$coverage" ]]; then exit 1 fi -echo "Running unit tests and code coverage for Spartacus core" +echo "Running unit tests and code coverage for core" exec 5>&1 output=$(ng test core --watch=false --sourceMap --code-coverage --browsers=ChromeHeadless | tee /dev/fd/5) coverage=$(echo $output | grep -i "does not meet global threshold" || true) @@ -22,7 +22,7 @@ if [[ -n "$coverage" ]]; then exit 1 fi -echo "Running unit tests and code coverage for storefront library" +echo "Running unit tests and code coverage for storefrontlib" exec 5>&1 output=$(ng test storefrontlib --sourceMap --watch=false --code-coverage --browsers=ChromeHeadless | tee /dev/fd/5) coverage=$(echo $output | grep -i "does not meet global threshold" || true) @@ -31,6 +31,22 @@ if [[ -n "$coverage" ]]; then exit 1 fi +echo "Running unit tests and code coverage for product library" +exec 5>&1 +output=$(ng test product --sourceMap --watch=false --code-coverage --browsers=ChromeHeadless | tee /dev/fd/5) + +echo "Running unit tests and code coverage for product-configurator library" +exec 5>&1 +output=$(ng test product-configurator --sourceMap --watch=false --code-coverage --browsers=ChromeHeadless | tee /dev/fd/5) +coverage=$(echo $output | grep -i "does not meet global threshold" || true) +if [[ -n "$coverage" ]]; then + echo "Error: Tests did not meet coverage expectations" + exit 1 +fi +echo "Running schematics unit tests and code coverage for product-configurator library" +exec 5>&1 +output=$(yarn --cwd feature-libs/product-configurator run test:schematics --coverage=true | tee /dev/fd/5) + echo "Running unit tests and code coverage for CDC" exec 5>&1 output=$(ng test cdc --sourceMap --watch=false --code-coverage --browsers=ChromeHeadless | tee /dev/fd/5) diff --git a/feature-libs/product-configurator/.release-it.json b/feature-libs/product-configurator/.release-it.json new file mode 100644 index 00000000000..3fff21921a5 --- /dev/null +++ b/feature-libs/product-configurator/.release-it.json @@ -0,0 +1,34 @@ +{ + "git": { + "requireCleanWorkingDir": true, + "requireUpstream": false, + "tagName": "product-configurator-${version}", + "commitMessage": "Bumping product-configurator version to ${version}", + "tagAnnotation": "Bumping product-configurator version to ${version}" + }, + "npm": { + "publishPath": "./../../dist/product-configurator" + }, + "hooks": { + "after:version:bump": "cd ../.. && ng build product-configurator --prod" + }, + "github": { + "release": true, + "assets": ["../../docs.tar.gz", "../../docs.zip"], + "releaseName": "@spartacus/product-configurator@${version}", + "releaseNotes": "ts-node ../../scripts/changelog.ts --verbose --lib product-configurator --to product-configurator-${version}" + }, + "plugins": { + "../../scripts/release-it/bumper.js": { + "out": [ + { + "file": "package.json", + "path": [ + "peerDependencies.@spartacus/core", + "peerDependencies.@spartacus/storefront" + ] + } + ] + } + } +} diff --git a/feature-libs/product-configurator/README.md b/feature-libs/product-configurator/README.md new file mode 100644 index 00000000000..a3058a9332f --- /dev/null +++ b/feature-libs/product-configurator/README.md @@ -0,0 +1,5 @@ +# Spartacus Product Configurator + +`@spartacus/product-configurator` is a package that you can include in your application, which allows you to use different product configurators. + +For more information, see [Spartacus](https://github.com/SAP/spartacus). diff --git a/feature-libs/product-configurator/_index.scss b/feature-libs/product-configurator/_index.scss new file mode 100644 index 00000000000..306636d5bb8 --- /dev/null +++ b/feature-libs/product-configurator/_index.scss @@ -0,0 +1,5 @@ +@import '~@spartacus/styles/scss/core'; + +@import './common/index'; +@import './rulebased/index'; +@import './textfield/index'; diff --git a/feature-libs/product-configurator/common/_index.scss b/feature-libs/product-configurator/common/_index.scss new file mode 100644 index 00000000000..d5d2f57dbb0 --- /dev/null +++ b/feature-libs/product-configurator/common/_index.scss @@ -0,0 +1,17 @@ +@import './styles/index'; + +$configurator-common-components: cx-configurator-issues-notification, + cx-configure-product, cx-configure-cart-entry, cx-configurator-cart-entry-info !default; + +@each $selector in $configurator-common-components { + #{$selector} { + @extend %#{$selector} !optional; + } +} + +// add body specific selectors +body { + @each $selector in $configurator-common-components { + @extend %#{$selector}__body !optional; + } +} diff --git a/feature-libs/product/configurators/common/ng-package.json b/feature-libs/product-configurator/common/assets/ng-package.json similarity index 100% rename from feature-libs/product/configurators/common/ng-package.json rename to feature-libs/product-configurator/common/assets/ng-package.json diff --git a/feature-libs/product-configurator/common/assets/public_api.ts b/feature-libs/product-configurator/common/assets/public_api.ts new file mode 100644 index 00000000000..2cfbde13adc --- /dev/null +++ b/feature-libs/product-configurator/common/assets/public_api.ts @@ -0,0 +1 @@ +export * from './translations/translations'; diff --git a/feature-libs/product-configurator/common/assets/translations/en/configurator-common.ts b/feature-libs/product-configurator/common/assets/translations/en/configurator-common.ts new file mode 100644 index 00000000000..87283686a58 --- /dev/null +++ b/feature-libs/product-configurator/common/assets/translations/en/configurator-common.ts @@ -0,0 +1,67 @@ +export const configurator = { + configurator: { + header: { + consistent: 'Consistent', + complete: 'Complete', + configId: 'Configuration ID', + toconfig: 'Configure', + editConfiguration: 'Edit Configuration', + displayConfiguration: 'Display Configuration', + resolveIssues: 'Resolve Issues', + updateMessage: 'The configuration is being updated in the background', + showMore: 'show more', + showLess: 'show less', + }, + tabBar: { + configuration: 'Configuration', + overview: 'Overview', + }, + notificationBanner: { + numberOfIssues: '{{count}} issue must be resolved before checkout.', + numberOfIssues_plural: + '{{count}} issues must be resolved before checkout.', + }, + attribute: { + caption: 'Attributes', + notSupported: 'Attibute Type is not supported.', + requiredAttribute: '{{param}} required', + defaultRequiredMessage: 'Enter a value for the required field', + singleSelectRequiredMessage: 'Select a value', + multiSelectRequiredMessage: 'Select one or more values', + wrongNumericFormat: + 'Wrong format, this numerical attribute should be entered according to pattern {{pattern}}', + }, + button: { + previous: 'Previous', + next: 'Next', + back: 'Back', + }, + priceSummary: { + basePrice: 'Base Price', + selectedOptions: 'Selected Options', + totalPrice: 'Total', + }, + addToCart: { + button: 'Add to Cart', + buttonAfterAddToCart: 'Continue to Cart', + buttonUpdateCart: 'Done', + confirmation: 'Configuration has been added to the cart', + confirmationUpdate: 'Cart has been updated with configuration', + }, + overviewForm: { + noAttributeHeader: 'No Results', + noAttributeText: 'Remove filter(s) to see Overview content', + }, + group: { + general: 'General', + conflictHeader: 'Resolve conflicts', + conflictGroup: 'Conflict for {{attribute}}', + }, + conflict: { + suggestionTitle: 'Suggestion {{number}}:', + suggestionText: 'Change value for "{{ attribute }}"', + viewConflictDetails: 'Conflict Detected', + viewConfigurationDetails: '', + }, + }, +}; diff --git a/feature-libs/product-configurator/common/assets/translations/en/index.ts b/feature-libs/product-configurator/common/assets/translations/en/index.ts new file mode 100644 index 00000000000..529d09021a7 --- /dev/null +++ b/feature-libs/product-configurator/common/assets/translations/en/index.ts @@ -0,0 +1,5 @@ +import { configurator } from './configurator-common'; + +export const en = { + configurator, +}; diff --git a/feature-libs/product-configurator/common/assets/translations/translations.ts b/feature-libs/product-configurator/common/assets/translations/translations.ts new file mode 100644 index 00000000000..8027556d38f --- /dev/null +++ b/feature-libs/product-configurator/common/assets/translations/translations.ts @@ -0,0 +1,8 @@ +import { TranslationChunksConfig, TranslationResources } from '@spartacus/core'; +import { en } from './en/index'; + +export const configuratorTranslations: TranslationResources = { + en, +}; +//empty for now but needed for schematics support +export const configuratorTranslationChunksConfig: TranslationChunksConfig = {}; diff --git a/feature-libs/product/configurators/common/src/common-configurator.module.ts b/feature-libs/product-configurator/common/common-configurator.module.ts similarity index 55% rename from feature-libs/product/configurators/common/src/common-configurator.module.ts rename to feature-libs/product-configurator/common/common-configurator.module.ts index 82251e03f55..9bbe1da785d 100644 --- a/feature-libs/product/configurators/common/src/common-configurator.module.ts +++ b/feature-libs/product-configurator/common/common-configurator.module.ts @@ -1,8 +1,8 @@ import { NgModule } from '@angular/core'; import { CommonConfiguratorComponentsModule } from './components/common-configurator-components.module'; -import { CommonConfiguratorCoreModule } from './core/common-configurator-core.module'; +import { CommonConfiguratorOccModule } from './occ/common-configurator-occ.module'; @NgModule({ - imports: [CommonConfiguratorCoreModule, CommonConfiguratorComponentsModule], + imports: [CommonConfiguratorOccModule, CommonConfiguratorComponentsModule], }) export class CommonConfiguratorModule {} diff --git a/feature-libs/product-configurator/common/components/common-configurator-components.module.ts b/feature-libs/product-configurator/common/components/common-configurator-components.module.ts new file mode 100644 index 00000000000..3f2f3f6afa3 --- /dev/null +++ b/feature-libs/product-configurator/common/components/common-configurator-components.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { ConfiguratorCartEntryInfoModule } from './configurator-cart-entry-info/configurator-cart-entry-info.module'; +import { ConfiguratorIssuesNotificationModule } from './configurator-issues-notification/configurator-issues-notification.module'; +import { ConfigureCartEntryModule } from './configure-cart-entry/configure-cart-entry.module'; +import { ConfigureProductModule } from './configure-product/configure-product.module'; + +@NgModule({ + imports: [ + ConfiguratorIssuesNotificationModule, + ConfiguratorCartEntryInfoModule, + ConfigureCartEntryModule, + ConfigureProductModule, + ], +}) +export class CommonConfiguratorComponentsModule {} diff --git a/feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.component.html b/feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.component.html new file mode 100644 index 00000000000..5fbd673fc80 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.component.html @@ -0,0 +1,21 @@ + + +
+
{{ info?.configurationLabel }}:
+
+ {{ info?.configurationValue }} +
+
+
+ + +
diff --git a/feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.component.spec.ts b/feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.component.spec.ts new file mode 100644 index 00000000000..f2b990995c0 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.component.spec.ts @@ -0,0 +1,151 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { + FeaturesConfigModule, + I18nTestingModule, + OrderEntry, +} from '@spartacus/core'; +import { CartItemContext, CartItemContextModel } from '@spartacus/storefront'; +import { BehaviorSubject } from 'rxjs'; +import { + ConfigurationInfo, + StatusSummary, +} from './../../core/model/common-configurator.model'; +import { ConfiguratorCartEntryInfoComponent } from './configurator-cart-entry-info.component'; + +function emitNewContextValue( + cartItemOutletConfiguratorComponent: ConfiguratorCartEntryInfoComponent, + statusSummary: StatusSummary[], + configurationInfos: ConfigurationInfo[], + readOnly: boolean +) { + const cartItemContext: CartItemContextModel = { + item: { + statusSummaryList: statusSummary, + configurationInfos: configurationInfos, + }, + readonly: readOnly, + }; + const context$ = cartItemOutletConfiguratorComponent.cartItemContext + .context$ as BehaviorSubject; + context$.next(cartItemContext); +} + +describe('ConfiguratorCartEntryInfoComponent', () => { + let configuratorCartEntryInfoComponent: ConfiguratorCartEntryInfoComponent; + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + ReactiveFormsModule, + I18nTestingModule, + FeaturesConfigModule, + ], + declarations: [ConfiguratorCartEntryInfoComponent], + providers: [ + CartItemContext, + { + provide: ControlContainer, + }, + ], + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorCartEntryInfoComponent); + + configuratorCartEntryInfoComponent = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should create CartItemOutletConfiguratorComponent', () => { + expect(configuratorCartEntryInfoComponent).toBeTruthy(); + }); + + it('should know cart item context', () => { + expect(configuratorCartEntryInfoComponent.cartItemContext).toBeTruthy(); + }); + + describe('configuration infos', () => { + it('should not be displayed if model provides empty array', () => { + emitNewContextValue( + configuratorCartEntryInfoComponent, + null, + null, + false + ); + const htmlElem = fixture.nativeElement; + expect(htmlElem.querySelectorAll('.cx-configuration-info').length).toBe( + 0, + "expected configuration info identified by selector '.cx-configuration-info' not to be present, but it is! innerHtml: " + + htmlElem.innerHTML + ); + }); + + it('should be displayed if model provides a success entry', () => { + emitNewContextValue( + configuratorCartEntryInfoComponent, + null, + [ + { + configurationLabel: 'Color', + configurationValue: 'Blue', + configuratorType: 'CPQCONFIGURATOR', + status: 'SUCCESS', + }, + ], + false + ); + + fixture.detectChanges(); + const htmlElem = fixture.nativeElement; + expect(htmlElem.querySelectorAll('.cx-configuration-info').length).toBe( + 1, + "expected configuration info identified by selector '.cx-configuration-info' to be present, but it is not! innerHtml: " + + htmlElem.innerHTML + ); + }); + + it('should be displayed if model provides a warning entry', () => { + emitNewContextValue( + configuratorCartEntryInfoComponent, + null, + [ + { + configurationLabel: 'Pricing', + configurationValue: 'could not be carried out', + configuratorType: 'CPQCONFIGURATOR', + status: 'WARNING', + }, + ], + false + ); + + fixture.detectChanges(); + const htmlElem = fixture.nativeElement; + expect(htmlElem.querySelectorAll('.cx-configuration-info').length).toBe( + 1, + "expected configuration info identified by selector '.cx-configuration-info' to be present, but it is not! innerHtml: " + + htmlElem.innerHTML + ); + }); + + describe('hasStatus', () => { + it('should be true if first entry of status summary is in error status', () => { + const entry: OrderEntry = { configurationInfos: [{ status: 'ERROR' }] }; + expect(configuratorCartEntryInfoComponent.hasStatus(entry)).toBe(true); + }); + + it('should be false if first entry of status summary carries no status', () => { + const entry: OrderEntry = { configurationInfos: [{ status: 'NONE' }] }; + expect(configuratorCartEntryInfoComponent.hasStatus(entry)).toBe(false); + }); + }); + }); +}); diff --git a/feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.component.ts b/feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.component.ts new file mode 100644 index 00000000000..f1ddcc360cc --- /dev/null +++ b/feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.component.ts @@ -0,0 +1,25 @@ +import { Component, Optional } from '@angular/core'; +import { OrderEntry } from '@spartacus/core'; +import { CartItemContext } from '@spartacus/storefront'; + +@Component({ + selector: 'cx-configurator-cart-entry-info', + templateUrl: './configurator-cart-entry-info.component.html', +}) +export class ConfiguratorCartEntryInfoComponent { + constructor(@Optional() public cartItemContext?: CartItemContext) {} + + /** + * Verifies whether the configuration infos have any entries and the first entry has a status. + * Only in this case we want to display the configuration summary + * + * @param {OrderEntry} item - Cart item + * @returns {boolean} - whether the status of configuration infos entry has status + */ + hasStatus(item: OrderEntry): boolean { + return ( + item?.configurationInfos?.length > 0 && + item?.configurationInfos[0]?.status !== 'NONE' + ); + } +} diff --git a/feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.module.ts b/feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.module.ts new file mode 100644 index 00000000000..441b977b3c9 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configurator-cart-entry-info/configurator-cart-entry-info.module.ts @@ -0,0 +1,31 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { I18nModule, UrlModule } from '@spartacus/core'; +import { + CartItemComponentOutlets, + IconModule, + provideOutlet, +} from '@spartacus/storefront'; +import { ConfiguratorIssuesNotificationModule } from '../configurator-issues-notification/configurator-issues-notification.module'; +import { ConfigureCartEntryModule } from '../configure-cart-entry/configure-cart-entry.module'; +import { ConfiguratorCartEntryInfoComponent } from './configurator-cart-entry-info.component'; + +@NgModule({ + imports: [ + CommonModule, + UrlModule, + I18nModule, + IconModule, + ConfiguratorIssuesNotificationModule, + ConfigureCartEntryModule, + ], + declarations: [ConfiguratorCartEntryInfoComponent], + + providers: [ + provideOutlet({ + id: CartItemComponentOutlets.INFORMATION, + component: ConfiguratorCartEntryInfoComponent, + }), + ], +}) +export class ConfiguratorCartEntryInfoModule {} diff --git a/feature-libs/product-configurator/common/components/configurator-cart-entry-info/index.ts b/feature-libs/product-configurator/common/components/configurator-cart-entry-info/index.ts new file mode 100644 index 00000000000..ecea6961b5f --- /dev/null +++ b/feature-libs/product-configurator/common/components/configurator-cart-entry-info/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-cart-entry-info.component'; +export * from './configurator-cart-entry-info.module'; diff --git a/feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.component.html b/feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.component.html new file mode 100644 index 00000000000..925c7119a3d --- /dev/null +++ b/feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.component.html @@ -0,0 +1,19 @@ + + + +
+ {{ + 'configurator.notificationBanner.numberOfIssues' + | cxTranslate: { count: getNumberOfIssues(ctx.item) } + }} + +
+
+
diff --git a/feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.component.spec.ts b/feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.component.spec.ts new file mode 100644 index 00000000000..bb216e86945 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.component.spec.ts @@ -0,0 +1,176 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { OrderEntry } from '@spartacus/core'; +import { CartItemContext, CartItemContextModel } from '@spartacus/storefront'; +import { BehaviorSubject } from 'rxjs'; +import { + ConfigurationInfo, + OrderEntryStatus, + StatusSummary, +} from './../../core/model/common-configurator.model'; +import { ConfiguratorIssuesNotificationComponent } from './configurator-issues-notification.component'; + +@Pipe({ + name: 'cxTranslate', +}) +class MockTranslatePipe implements PipeTransform { + transform(): any {} +} + +let item: OrderEntry; +function emitNewContextValue( + cartItemOutletConfiguratorComponent: ConfiguratorIssuesNotificationComponent, + statusSummary: StatusSummary[], + configurationInfos: ConfigurationInfo[], + readOnly: boolean, + productConfigurable: boolean = true +) { + item = { + statusSummaryList: statusSummary, + configurationInfos: configurationInfos, + product: { configurable: productConfigurable }, + }; + const cartItemContext: any = { + item: item, + readonly: readOnly, + quantityControl: {}, + }; + const context$ = cartItemOutletConfiguratorComponent.cartItemContext + .context$ as BehaviorSubject; + context$.next(cartItemContext); +} + +describe('ConfigureIssuesNotificationComponent', () => { + let component: ConfiguratorIssuesNotificationComponent; + let fixture: ComponentFixture; + let htmlElem: HTMLElement; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + ConfiguratorIssuesNotificationComponent, + MockTranslatePipe, + ], + providers: [CartItemContext], + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorIssuesNotificationComponent); + component = fixture.componentInstance; + htmlElem = fixture.nativeElement; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should know cart context', () => { + expect(component.cartItemContext).toBeDefined(); + }); + + it('should return number of issues of ERROR status', () => { + emitNewContextValue( + component, + [{ numberOfIssues: 2, status: OrderEntryStatus.Error }], + null, + false + ); + expect(component.getNumberOfIssues(item)).toBe(2); + }); + + it('should return number of issues of ERROR status if ERROR and SUCCESS statuses are present', () => { + emitNewContextValue( + component, + [ + { numberOfIssues: 1, status: OrderEntryStatus.Success }, + { numberOfIssues: 3, status: OrderEntryStatus.Error }, + ], + null, + false + ); + + expect(component.getNumberOfIssues(item)).toBe(3); + }); + + it('should return number of issues as 0 if only SUCCESS status is present', () => { + emitNewContextValue( + component, + [{ numberOfIssues: 2, status: OrderEntryStatus.Success }], + null, + false + ); + + expect(component.getNumberOfIssues(item)).toBe(0); + }); + + it('should return number of issues as 0 if statusSummaryList is undefined', () => { + emitNewContextValue(component, null, null, false); + expect(component.getNumberOfIssues(item)).toBe(0); + }); + + it('should return number of issues as 0 if statusSummaryList is empty', () => { + emitNewContextValue(component, [], null, false); + expect(component.getNumberOfIssues(item)).toBe(0); + }); + + it('should display configure from cart in case issues are present', () => { + emitNewContextValue( + component, + [{ numberOfIssues: 2, status: OrderEntryStatus.Error }], + null, + false + ); + + fixture.detectChanges(); + expect(component.hasIssues(item)).toBeTrue(); + + expect( + htmlElem.querySelectorAll('cx-configure-cart-entry').length + ).toBeGreaterThan( + 0, + 'expected configure cart entry to be present, but it is not; innerHtml: ' + + htmlElem.innerHTML + ); + }); + + it('should not display configure from cart in case issues are present but product not configurable', () => { + emitNewContextValue( + component, + [{ numberOfIssues: 2, status: OrderEntryStatus.Error }], + null, + false, + false + ); + + fixture.detectChanges(); + expect(component.hasIssues(item)).toBeTrue(); + + expect(htmlElem.querySelectorAll('cx-configure-cart-entry').length).toBe( + 0, + 'expected configure cart entry not to be present, but it is; innerHtml: ' + + htmlElem.innerHTML + ); + }); + + it('should return false if number of issues of ERROR status is = 0', () => { + emitNewContextValue( + component, + [{ numberOfIssues: 2, status: OrderEntryStatus.Success }], + null, + false + ); + + fixture.detectChanges(); + expect(component.hasIssues(item)).toBeFalse(); + expect(htmlElem.querySelectorAll('cx-configure-cart-entry').length).toBe( + 0, + 'expected configure cart entry not to be present, but it is; innerHtml: ' + + htmlElem.innerHTML + ); + }); +}); diff --git a/feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.component.ts b/feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.component.ts new file mode 100644 index 00000000000..c80d8432b68 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.component.ts @@ -0,0 +1,38 @@ +import { Component, Optional } from '@angular/core'; +import { OrderEntry } from '@spartacus/core'; +import { CartItemContext, ICON_TYPE } from '@spartacus/storefront'; +import { CommonConfiguratorUtilsService } from '../../shared/utils/common-configurator-utils.service'; + +@Component({ + selector: 'cx-configurator-issues-notification', + templateUrl: './configurator-issues-notification.component.html', +}) +export class ConfiguratorIssuesNotificationComponent { + iconTypes = ICON_TYPE; + + constructor( + protected commonConfigUtilsService: CommonConfiguratorUtilsService, + @Optional() + public cartItemContext?: CartItemContext + ) {} + + /** + * Verifies whether the item has any issues. + * + * @param {OrderEntry} item - Cart item + * @returns {boolean} - whether there are any issues + */ + hasIssues(item: OrderEntry): boolean { + return this.commonConfigUtilsService.hasIssues(item); + } + + /** + * Retrieves the number of issues at the cart item. + * + * @param {OrderEntry} item - Cart item + * @returns {number} - the number of issues at the cart item + */ + getNumberOfIssues(item: OrderEntry): number { + return this.commonConfigUtilsService.getNumberOfIssues(item); + } +} diff --git a/feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.module.ts b/feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.module.ts new file mode 100644 index 00000000000..939df0cfb07 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configurator-issues-notification/configurator-issues-notification.module.ts @@ -0,0 +1,29 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { I18nModule, UrlModule } from '@spartacus/core'; +import { + CartItemComponentOutlets, + IconModule, + provideOutlet, +} from '@spartacus/storefront'; +import { ConfigureCartEntryModule } from '../configure-cart-entry/configure-cart-entry.module'; +import { ConfiguratorIssuesNotificationComponent } from './configurator-issues-notification.component'; + +@NgModule({ + imports: [ + CommonModule, + UrlModule, + I18nModule, + IconModule, + ConfigureCartEntryModule, + ], + declarations: [ConfiguratorIssuesNotificationComponent], + providers: [ + provideOutlet({ + id: CartItemComponentOutlets.START, + component: ConfiguratorIssuesNotificationComponent, + }), + ], + exports: [ConfiguratorIssuesNotificationComponent], +}) +export class ConfiguratorIssuesNotificationModule {} diff --git a/feature-libs/product-configurator/common/components/configurator-issues-notification/index.ts b/feature-libs/product-configurator/common/components/configurator-issues-notification/index.ts new file mode 100644 index 00000000000..64a94f593d1 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configurator-issues-notification/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-issues-notification.component'; +export * from './configurator-issues-notification.module'; diff --git a/feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.component.html b/feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.component.html new file mode 100644 index 00000000000..36823d61c47 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.component.html @@ -0,0 +1,38 @@ + + + + + + + + + + + {{ 'configurator.header.displayConfiguration' | cxTranslate }} + + {{ 'configurator.header.editConfiguration' | cxTranslate }} + + + + {{ 'configurator.header.resolveIssues' | cxTranslate }} + + diff --git a/feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.component.spec.ts b/feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.component.spec.ts new file mode 100644 index 00000000000..4b78a90f7d2 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.component.spec.ts @@ -0,0 +1,199 @@ +import { Directive, Input, Pipe, PipeTransform } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { I18nTestingModule, OrderEntry } from '@spartacus/core'; +import { ModalDirective } from '@spartacus/storefront'; +import { CommonConfigurator } from '../../core/model/common-configurator.model'; +import { CommonConfiguratorTestUtilsService } from '../../shared/testing/common-configurator-test-utils.service'; +import { ConfigureCartEntryComponent } from './configure-cart-entry.component'; + +@Pipe({ + name: 'cxUrl', +}) +class MockUrlPipe implements PipeTransform { + transform(): any {} +} + +@Directive({ + selector: '[cxModal]', +}) +class MockModalDirective implements Partial { + @Input() cxModal; +} + +describe('ConfigureCartEntryComponent', () => { + let component: ConfigureCartEntryComponent; + let fixture: ComponentFixture; + let htmlElem: HTMLElement; + const configuratorType = 'type'; + const orderOrCartEntry: OrderEntry = {}; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [I18nTestingModule, RouterTestingModule], + declarations: [ + ConfigureCartEntryComponent, + MockUrlPipe, + MockModalDirective, + ], + }).compileComponents(); + }) + ); + beforeEach(() => { + fixture = TestBed.createComponent(ConfigureCartEntryComponent); + component = fixture.componentInstance; + htmlElem = fixture.nativeElement; + component.cartEntry = orderOrCartEntry; + }); + + it('should create component', () => { + expect(component).toBeDefined(); + }); + + it('should find correct default owner type', () => { + orderOrCartEntry.orderCode = undefined; + expect(component.getOwnerType()).toBe( + CommonConfigurator.OwnerType.CART_ENTRY + ); + }); + + it('should find correct owner type in case entry knows order', () => { + component.readOnly = true; + orderOrCartEntry.orderCode = '112'; + expect(component.getOwnerType()).toBe( + CommonConfigurator.OwnerType.ORDER_ENTRY + ); + }); + + it('should find correct entity key for cart entry', () => { + component.cartEntry = { entryNumber: 0 }; + expect(component.getEntityKey()).toBe('0'); + }); + + it('should compile correct route for cart entry', () => { + component.cartEntry = { + entryNumber: 0, + product: { configuratorType: configuratorType }, + }; + expect(component.getRoute()).toBe('configure' + configuratorType); + }); + + it('should compile correct route for order entry', () => { + component.readOnly = true; + component.cartEntry = { + entryNumber: 0, + product: { configuratorType: configuratorType }, + }; + expect(component.getRoute()).toBe('configureOverview' + configuratorType); + }); + + it('should compile displayOnly method', () => { + component.readOnly = true; + expect(component.getDisplayOnly()).toBe(true); + }); + + it("should return 'false' for disabled when readOnly true", () => { + component.readOnly = true; + expect(component.isDisabled()).toBe(false); + }); + + it('should return disabled value when readOnly false', () => { + component.readOnly = false; + component.disabled = true; + expect(component.isDisabled()).toBe(component.disabled); + }); + + describe('Link text', () => { + it("should be 'Display Configuration' in case component is included in readOnly mode", () => { + component.readOnly = true; + component.cartEntry = { + entryNumber: 0, + product: { configuratorType: configuratorType }, + }; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementToContainText( + expect, + htmlElem, + 'a', + 'configurator.header.displayConfiguration' + ); + }); + + it("should be 'Edit Configuration' in case component is included in edit mode", () => { + component.readOnly = false; + component.disabled = false; + component.msgBanner = false; + component.cartEntry = { + entryNumber: 0, + product: { configuratorType: configuratorType }, + }; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementToContainText( + expect, + htmlElem, + 'a', + 'configurator.header.editConfiguration' + ); + }); + + it("should be 'Resolve Issues' in case component is used in banner", () => { + component.readOnly = false; + component.msgBanner = true; + component.cartEntry = { + entryNumber: 0, + product: { configuratorType: configuratorType }, + }; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementToContainText( + expect, + htmlElem, + 'a', + 'configurator.header.resolveIssues' + ); + }); + }); + + describe('a', () => { + it('should be disabled in case corresponding component attribute is disabled', () => { + component.disabled = true; + component.cartEntry = { + entryNumber: 0, + product: { configuratorType: configuratorType }, + }; + fixture.detectChanges(); + + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'label.disabled-link' + ); + + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + 'a.link' + ); + }); + + it('should be enabled in case corresponding component attribute is enabled', () => { + component.disabled = false; + component.cartEntry = { + entryNumber: 0, + product: { configuratorType: configuratorType }, + }; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'a.link' + ); + + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + 'label.disabled-link' + ); + }); + }); +}); diff --git a/feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.component.ts b/feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.component.ts new file mode 100644 index 00000000000..3279b039b05 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.component.ts @@ -0,0 +1,85 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { OrderEntry } from '@spartacus/core'; +import { CommonConfigurator } from '../../core/model/common-configurator.model'; +import { CommonConfiguratorUtilsService } from '../../shared/utils/common-configurator-utils.service'; + +@Component({ + selector: 'cx-configure-cart-entry', + templateUrl: './configure-cart-entry.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfigureCartEntryComponent { + @Input() cartEntry: OrderEntry; + @Input() readOnly: boolean; + @Input() msgBanner: boolean; + @Input() disabled: boolean; + + /** + * Verifies whether the entry has any issues. + * + * @returns {boolean} - whether there are any issues + */ + hasIssues(): boolean { + return this.commonConfigUtilsService.hasIssues(this.cartEntry); + } + + /** + * Verifies whether the cart entry has an order code and returns a corresponding owner type. + * + * @returns {CommonConfigurator.OwnerType} - an owner type + */ + getOwnerType(): CommonConfigurator.OwnerType { + return this.cartEntry.orderCode !== undefined + ? CommonConfigurator.OwnerType.ORDER_ENTRY + : CommonConfigurator.OwnerType.CART_ENTRY; + } + + /** + * Verifies whether the cart entry has an order code, retrieves a composed owner ID + * and concatenates a corresponding entry number. + * + * @returns {string} - an entry key + */ + getEntityKey(): string { + return this.cartEntry.orderCode !== undefined + ? this.commonConfigUtilsService.getComposedOwnerId( + this.cartEntry.orderCode, + this.cartEntry.entryNumber + ) + : '' + this.cartEntry.entryNumber; + } + + /** + * Retrieves a corresponding route depending whether the configuration is read only or not. + * + * @returns {string} - a route + */ + getRoute(): string { + const configuratorType = this.cartEntry.product.configuratorType; + return this.readOnly + ? 'configureOverview' + configuratorType + : 'configure' + configuratorType; + } + + /** + * Retrieves the state of the configuration. + * + * @returns {boolean} - 'true' if the configuration is read only, otherwise 'false' + */ + getDisplayOnly(): boolean { + return this.readOnly; + } + + /** + * Verifies whether the link to the configuration is disabled. + * + * @returns {boolean} - 'true' if the the configuration is not read only, otherwise 'false' + */ + isDisabled() { + return this.readOnly ? false : this.disabled; + } + + constructor( + protected commonConfigUtilsService: CommonConfiguratorUtilsService + ) {} +} diff --git a/feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.module.ts b/feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.module.ts new file mode 100644 index 00000000000..7f915456772 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configure-cart-entry/configure-cart-entry.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { I18nModule, UrlModule } from '@spartacus/core'; +import { IconModule, ModalModule } from '@spartacus/storefront'; +import { ConfigureCartEntryComponent } from './configure-cart-entry.component'; + +@NgModule({ + imports: [ + CommonModule, + UrlModule, + I18nModule, + IconModule, + RouterModule, + ModalModule, + ], + declarations: [ConfigureCartEntryComponent], + exports: [ConfigureCartEntryComponent], +}) +export class ConfigureCartEntryModule {} diff --git a/feature-libs/product-configurator/common/components/configure-cart-entry/index.ts b/feature-libs/product-configurator/common/components/configure-cart-entry/index.ts new file mode 100644 index 00000000000..3fa55659335 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configure-cart-entry/index.ts @@ -0,0 +1,2 @@ +export * from './configure-cart-entry.component'; +export * from './configure-cart-entry.module'; diff --git a/feature-libs/product-configurator/common/components/configure-product/configure-product.component.html b/feature-libs/product-configurator/common/components/configure-product/configure-product.component.html new file mode 100644 index 00000000000..c29b88ace94 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configure-product/configure-product.component.html @@ -0,0 +1,18 @@ + + + {{ 'configurator.header.toconfig' | cxTranslate }} + + diff --git a/feature-libs/product-configurator/common/components/configure-product/configure-product.component.spec.ts b/feature-libs/product-configurator/common/components/configure-product/configure-product.component.spec.ts new file mode 100644 index 00000000000..b9bb2b93d31 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configure-product/configure-product.component.spec.ts @@ -0,0 +1,148 @@ +import { Pipe, PipeTransform, Type } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { I18nTestingModule, Product } from '@spartacus/core'; +import { + CurrentProductService, + ProductListItemContext, + ProductListItemContextOwner, +} from '@spartacus/storefront'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorProductScope } from '../../core/model/configurator-product-scope'; +import { CommonConfiguratorTestUtilsService } from '../../shared/testing/common-configurator-test-utils.service'; +import { ConfigureProductComponent } from './configure-product.component'; + +const productCode = 'CONF_LAPTOP'; +const configuratorType = 'CPQCONFIGURATOR'; +const mockProduct: Product = { + code: productCode, + configurable: true, + configuratorType: configuratorType, +}; + +class MockCurrentProductService { + getProduct(): Observable { + return of(mockProduct); + } +} + +@Pipe({ + name: 'cxUrl', +}) +class MockUrlPipe implements PipeTransform { + transform(): any {} +} + +let component: ConfigureProductComponent; +let currentProductService: CurrentProductService; +let productListItemContext: ProductListItemContext; +let fixture: ComponentFixture; +let htmlElem: HTMLElement; + +function setupWithCurrentProductService(useCurrentProductServiceOnly: boolean) { + if (useCurrentProductServiceOnly) { + TestBed.configureTestingModule({ + imports: [I18nTestingModule, RouterTestingModule], + declarations: [ConfigureProductComponent, MockUrlPipe], + providers: [ + { + provide: CurrentProductService, + useClass: MockCurrentProductService, + }, + ], + }).compileComponents(); + } else { + TestBed.configureTestingModule({ + imports: [I18nTestingModule, RouterTestingModule], + declarations: [ConfigureProductComponent, MockUrlPipe], + providers: [ + { + provide: ProductListItemContext, + useClass: ProductListItemContextOwner, + }, + { + provide: CurrentProductService, + useClass: MockCurrentProductService, + }, + ], + }).compileComponents(); + productListItemContext = TestBed.inject( + ProductListItemContext as Type + ); + if (productListItemContext) { + (productListItemContext as ProductListItemContextOwner).setProduct( + mockProduct + ); + } + } + + currentProductService = TestBed.inject( + CurrentProductService as Type + ); + + spyOn(currentProductService, 'getProduct').and.callThrough(); + fixture = TestBed.createComponent(ConfigureProductComponent); + component = fixture.componentInstance; + htmlElem = fixture.nativeElement; +} + +describe('ConfigureProductComponent', () => { + it('should create component', () => { + setupWithCurrentProductService(true); + expect(component).toBeDefined(); + }); + + it('should call currentProductService with configurator scope only as we do not need more scopes', () => { + setupWithCurrentProductService(true); + expect(currentProductService.getProduct).toHaveBeenCalledWith( + ConfiguratorProductScope.CONFIGURATOR + ); + }); + + it('should show button', () => { + setupWithCurrentProductService(true); + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.btn' + ); + }); + + it('should display configure button text', () => { + setupWithCurrentProductService(true); + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementToContainText( + expect, + htmlElem, + '.btn', + 'configurator.header.toconfig' + ); + }); + + it('should emit product in case it was launched with current product service', (done) => { + setupWithCurrentProductService(true); + component.product$.subscribe((product) => { + expect(product).toBe(mockProduct); + done(); + }); + }); + + it('should show button in case it was launched with product item context', () => { + setupWithCurrentProductService(false); + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.btn' + ); + }); + + it('should emit product in case it was launched with product item context', (done) => { + setupWithCurrentProductService(false); + component.product$.subscribe((product) => { + expect(product).toBe(mockProduct); + done(); + }); + }); +}); diff --git a/feature-libs/product-configurator/common/components/configure-product/configure-product.component.ts b/feature-libs/product-configurator/common/components/configure-product/configure-product.component.ts new file mode 100644 index 00000000000..517cf288078 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configure-product/configure-product.component.ts @@ -0,0 +1,32 @@ +import { ChangeDetectionStrategy, Component, Optional } from '@angular/core'; +import { Product } from '@spartacus/core'; +import { + CurrentProductService, + ProductListItemContext, +} from '@spartacus/storefront'; +import { Observable, of } from 'rxjs'; +import { CommonConfigurator } from '../../core/model/common-configurator.model'; +import { ConfiguratorProductScope } from '../../core/model/configurator-product-scope'; + +@Component({ + selector: 'cx-configure-product', + templateUrl: './configure-product.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfigureProductComponent { + product$: Observable = this.productListItemContext + ? this.productListItemContext.product$ + : this.currentProductService + ? this.currentProductService.getProduct( + ConfiguratorProductScope.CONFIGURATOR + ) + : of(null); + + ownerTypeProduct: CommonConfigurator.OwnerType = + CommonConfigurator.OwnerType.PRODUCT; + + constructor( + @Optional() protected productListItemContext: ProductListItemContext, + @Optional() protected currentProductService: CurrentProductService + ) {} +} diff --git a/feature-libs/product-configurator/common/components/configure-product/configure-product.module.ts b/feature-libs/product-configurator/common/components/configure-product/configure-product.module.ts new file mode 100644 index 00000000000..1ca4dcd08a5 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configure-product/configure-product.module.ts @@ -0,0 +1,46 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { + CmsConfig, + ConfigModule, + I18nModule, + UrlModule, +} from '@spartacus/core'; +import { + IconModule, + ProductListOutlets, + provideOutlet, +} from '@spartacus/storefront'; +import { ConfigureProductComponent } from './configure-product.component'; + +@NgModule({ + imports: [ + CommonModule, + RouterModule, + ConfigModule.withConfig({ + cmsComponents: { + ConfigureProductComponent: { + component: ConfigureProductComponent, + }, + }, + }), + UrlModule, + I18nModule, + IconModule, + ], + providers: [ + provideOutlet({ + id: ProductListOutlets.GRID_ITEM_END, + component: ConfigureProductComponent, + }), + provideOutlet({ + id: ProductListOutlets.LIST_ITEM_END, + component: ConfigureProductComponent, + }), + ], + declarations: [ConfigureProductComponent], + entryComponents: [ConfigureProductComponent], + exports: [ConfigureProductComponent], +}) +export class ConfigureProductModule {} diff --git a/feature-libs/product-configurator/common/components/configure-product/index.ts b/feature-libs/product-configurator/common/components/configure-product/index.ts new file mode 100644 index 00000000000..7e25bb714b3 --- /dev/null +++ b/feature-libs/product-configurator/common/components/configure-product/index.ts @@ -0,0 +1,2 @@ +export * from './configure-product.component'; +export * from './configure-product.module'; diff --git a/feature-libs/product-configurator/common/components/index.ts b/feature-libs/product-configurator/common/components/index.ts new file mode 100644 index 00000000000..4de5d62fcdf --- /dev/null +++ b/feature-libs/product-configurator/common/components/index.ts @@ -0,0 +1,6 @@ +export * from './common-configurator-components.module'; +export * from './configurator-cart-entry-info/index'; +export * from './configurator-issues-notification/index'; +export * from './configure-cart-entry/index'; +export * from './configure-product/index'; +export * from './service/index'; diff --git a/feature-libs/product-configurator/common/components/service/configurator-router-data.ts b/feature-libs/product-configurator/common/components/service/configurator-router-data.ts new file mode 100644 index 00000000000..03888cc32e4 --- /dev/null +++ b/feature-libs/product-configurator/common/components/service/configurator-router-data.ts @@ -0,0 +1,15 @@ +import { CommonConfigurator } from '../../core/model/common-configurator.model'; +export namespace ConfiguratorRouter { + export enum PageType { + CONFIGURATION = 'configuration', + OVERVIEW = 'overview', + } + export interface Data { + pageType?: PageType; + isOwnerCartEntry?: boolean; + owner?: CommonConfigurator.Owner; + displayOnly?: boolean; + forceReload?: boolean; + resolveIssues?: boolean; + } +} diff --git a/feature-libs/product-configurator/common/components/service/configurator-router-extractor.service.spec.ts b/feature-libs/product-configurator/common/components/service/configurator-router-extractor.service.spec.ts new file mode 100644 index 00000000000..68170ca0cb1 --- /dev/null +++ b/feature-libs/product-configurator/common/components/service/configurator-router-extractor.service.spec.ts @@ -0,0 +1,194 @@ +import { Type } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { + I18nTestingModule, + RouterState, + RoutingService, +} from '@spartacus/core'; +import { Observable, of } from 'rxjs'; +import { CommonConfigurator } from '../../core/model/common-configurator.model'; +import { ConfiguratorRouter } from './configurator-router-data'; +import { ConfiguratorRouterExtractorService } from './configurator-router-extractor.service'; + +const PRODUCT_CODE = 'CONF_LAPTOP'; +const CART_ENTRY_NUMBER = '0'; +const CONFIGURATOR_TYPE = 'CPQCONFIGURATOR'; +const CONFIGURATOR_ROUTE = 'configureCPQCONFIGURATOR'; +const OVERVIEW_ROUTE = 'configureOverviewCPQCONFIGURATOR'; + +let mockRouterState: any; + +class MockRoutingService { + getRouterState(): Observable { + return of(mockRouterState); + } +} + +describe('ConfigRouterExtractorService', () => { + let serviceUnderTest: ConfiguratorRouterExtractorService; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [I18nTestingModule, RouterTestingModule], + providers: [ + { + provide: RoutingService, + useClass: MockRoutingService, + }, + ], + }).compileComponents(); + }) + ); + beforeEach(() => { + serviceUnderTest = TestBed.inject( + ConfiguratorRouterExtractorService as Type< + ConfiguratorRouterExtractorService + > + ); + + mockRouterState = { + state: { + params: { + entityKey: PRODUCT_CODE, + ownerType: CommonConfigurator.OwnerType.PRODUCT, + }, + queryParams: {}, + semanticRoute: CONFIGURATOR_ROUTE, + }, + }; + }); + + it('should create component', () => { + expect(serviceUnderTest).toBeDefined(); + }); + describe('extractRouterData', () => { + it('should find proper owner for route based purely on product code', () => { + let owner: CommonConfigurator.Owner; + serviceUnderTest + .extractRouterData() + .subscribe((routerData) => (owner = routerData.owner)); + expect(owner.id).toBe(PRODUCT_CODE); + expect(owner.type).toBe(CommonConfigurator.OwnerType.PRODUCT); + expect(owner.key.includes(CommonConfigurator.OwnerType.PRODUCT)).toBe( + true + ); + }); + + it('should find proper owner for route based on owner type PRODUCT and product code', () => { + let owner: CommonConfigurator.Owner; + mockRouterState.state.params.ownerType = + CommonConfigurator.OwnerType.PRODUCT; + mockRouterState.state.params.entityKey = PRODUCT_CODE; + + serviceUnderTest + .extractRouterData() + .subscribe((routerData) => (owner = routerData.owner)); + expect(owner.id).toBe(PRODUCT_CODE); + expect(owner.type).toBe(CommonConfigurator.OwnerType.PRODUCT); + expect(owner.key.includes(CommonConfigurator.OwnerType.PRODUCT)).toBe( + true + ); + }); + + it('should find proper owner for route based on owner type CART_ENTRY and cart entry number', () => { + let owner: CommonConfigurator.Owner; + mockRouterState.state.params.ownerType = + CommonConfigurator.OwnerType.CART_ENTRY; + mockRouterState.state.params.entityKey = CART_ENTRY_NUMBER; + + serviceUnderTest + .extractRouterData() + .subscribe((routerData) => (owner = routerData.owner)); + expect(owner.id).toBe(CART_ENTRY_NUMBER); + expect(owner.type).toBe(CommonConfigurator.OwnerType.CART_ENTRY); + expect(owner.key.includes(CommonConfigurator.OwnerType.CART_ENTRY)).toBe( + true + ); + }); + + it('should determine configurator and page type from router state ', () => { + let routerData: ConfiguratorRouter.Data; + serviceUnderTest + .extractRouterData() + .subscribe((data) => (routerData = data)); + expect(routerData.owner.configuratorType).toBe(CONFIGURATOR_TYPE); + expect(routerData.isOwnerCartEntry).toBe(false); + expect(routerData.pageType).toBe( + ConfiguratorRouter.PageType.CONFIGURATION + ); + }); + + it('should determine configurator and page type from router based on owner type CART_ENTRY and cart entry number ', () => { + mockRouterState.state.params.ownerType = + CommonConfigurator.OwnerType.CART_ENTRY; + mockRouterState.state.params.entityKey = CART_ENTRY_NUMBER; + mockRouterState.state.semanticRoute = OVERVIEW_ROUTE; + let routerData: ConfiguratorRouter.Data; + serviceUnderTest + .extractRouterData() + .subscribe((data) => (routerData = data)) + .unsubscribe(); + expect(routerData.owner.configuratorType).toBe(CONFIGURATOR_TYPE); + expect(routerData.isOwnerCartEntry).toBe(true); + expect(routerData.pageType).toBe(ConfiguratorRouter.PageType.OVERVIEW); + expect(routerData.forceReload).toBe(false); + }); + + it('should tell from the URL if we need to enforce a reload of a configuration', () => { + mockRouterState.state.queryParams = { forceReload: 'true' }; + let routerData: ConfiguratorRouter.Data; + serviceUnderTest + .extractRouterData() + .subscribe((data) => (routerData = data)) + .unsubscribe(); + + expect(routerData.forceReload).toBe(true); + }); + + it('should tell from the URL if we need to resolve issues of a configuration', () => { + mockRouterState.state.queryParams = { resolveIssues: 'true' }; + let routerData: ConfiguratorRouter.Data; + serviceUnderTest + .extractRouterData() + .subscribe((data) => (routerData = data)) + .unsubscribe(); + + expect(routerData.resolveIssues).toBe(true); + }); + }); + + describe('createOwnerFromRouterState', () => { + it('should create owner from router state correctly', () => { + const owner: CommonConfigurator.Owner = serviceUnderTest.createOwnerFromRouterState( + mockRouterState + ); + + expect(owner.type).toBe(CommonConfigurator.OwnerType.PRODUCT); + }); + + it('should create owner from router state if owner type is not provided', () => { + mockRouterState.state.params = { + rootProduct: PRODUCT_CODE, + }; + const owner: CommonConfigurator.Owner = serviceUnderTest.createOwnerFromRouterState( + mockRouterState + ); + + expect(owner.type).toBe(CommonConfigurator.OwnerType.PRODUCT); + }); + + it('should detect an obsolete state of a cart related owner type', () => { + mockRouterState.state.params.ownerType = + CommonConfigurator.OwnerType.CART_ENTRY; + mockRouterState.state.params.entityKey = CART_ENTRY_NUMBER; + + const owner: CommonConfigurator.Owner = serviceUnderTest.createOwnerFromRouterState( + mockRouterState + ); + + expect(owner.type).toBe(CommonConfigurator.OwnerType.CART_ENTRY); + }); + }); +}); diff --git a/feature-libs/product-configurator/common/components/service/configurator-router-extractor.service.ts b/feature-libs/product-configurator/common/components/service/configurator-router-extractor.service.ts new file mode 100644 index 00000000000..20741076ece --- /dev/null +++ b/feature-libs/product-configurator/common/components/service/configurator-router-extractor.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@angular/core'; +import { RouterState, RoutingService } from '@spartacus/core'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { CommonConfigurator } from '../../core/model/common-configurator.model'; +import { CommonConfiguratorUtilsService } from '../../shared/utils/common-configurator-utils.service'; +import { ConfiguratorRouter } from './configurator-router-data'; + +/** + * Service to extract the configuration owner key from the current route + */ +@Injectable({ providedIn: 'root' }) +export class ConfiguratorRouterExtractorService { + protected readonly ROUTE_FRAGMENT_CONFIGURE = 'configure'; + protected readonly ROUTE_FRAGMENT_OVERVIEW = 'configureOverview'; + constructor( + protected configUtilsService: CommonConfiguratorUtilsService, + protected routingService: RoutingService + ) {} + + extractRouterData(): Observable { + return this.routingService.getRouterState().pipe( + filter((routingData) => routingData.state.params.entityKey), + //we don't need to cover the intermediate router states where a future route is already known. + //only changes to the URL are relevant. Otherwise we get wrong hits where e.g. the config form fires although + //the OV already loads + filter((routingData) => routingData.nextState === undefined), + map((routingData) => { + const owner = this.createOwnerFromRouterState(routingData); + + const routerData: ConfiguratorRouter.Data = { + owner: owner, + isOwnerCartEntry: + owner.type === CommonConfigurator.OwnerType.CART_ENTRY, + displayOnly: routingData.state.params.displayOnly, + resolveIssues: + routingData.state.queryParams?.resolveIssues === 'true', + forceReload: routingData.state?.queryParams?.forceReload === 'true', + pageType: routingData.state.semanticRoute.includes( + this.ROUTE_FRAGMENT_OVERVIEW + ) + ? ConfiguratorRouter.PageType.OVERVIEW + : ConfiguratorRouter.PageType.CONFIGURATION, + }; + + return routerData; + }) + ); + } + + createOwnerFromRouterState( + routerState: RouterState + ): CommonConfigurator.Owner { + const owner: CommonConfigurator.Owner = {}; + const params = routerState.state.params; + if (params.ownerType) { + const entityKey = params.entityKey; + owner.type = params.ownerType; + + owner.id = entityKey; + } else { + owner.type = CommonConfigurator.OwnerType.PRODUCT; + owner.id = params.rootProduct; + } + const configuratorType = this.getConfiguratorTypeFromSemanticRoute( + routerState.state.semanticRoute + ); + owner.configuratorType = configuratorType; + this.configUtilsService.setOwnerKey(owner); + return owner; + } + + /** + * Compiles the configurator type from the semantic route + * @param semanticRoute Consists of a prefix that indicates if target is interactive configuration or overview and + * the commerce configurator type as postfix. + * Example: configureTEXTFIELD or configureOverviewCPQCONFIGURATOR + * @returns Configurator type + */ + protected getConfiguratorTypeFromSemanticRoute( + semanticRoute: string + ): string { + let configuratorType: string; + if (semanticRoute.startsWith(this.ROUTE_FRAGMENT_OVERVIEW)) { + configuratorType = semanticRoute.split(this.ROUTE_FRAGMENT_OVERVIEW)[1]; + } else if (semanticRoute.startsWith(this.ROUTE_FRAGMENT_CONFIGURE)) { + configuratorType = semanticRoute.split(this.ROUTE_FRAGMENT_CONFIGURE)[1]; + } + return configuratorType; + } +} diff --git a/feature-libs/product-configurator/common/components/service/index.ts b/feature-libs/product-configurator/common/components/service/index.ts new file mode 100644 index 00000000000..3a8932bd733 --- /dev/null +++ b/feature-libs/product-configurator/common/components/service/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-router-data'; +export * from './configurator-router-extractor.service'; diff --git a/feature-libs/product-configurator/common/core/model/augmented-core.model.ts b/feature-libs/product-configurator/common/core/model/augmented-core.model.ts new file mode 100644 index 00000000000..7d9ba2d78e3 --- /dev/null +++ b/feature-libs/product-configurator/common/core/model/augmented-core.model.ts @@ -0,0 +1,11 @@ +import { ConfigurationInfo, StatusSummary } from './common-configurator.model'; +declare module '@spartacus/core' { + interface Product { + configurable?: boolean; + configuratorType?: string; + } + interface OrderEntry { + statusSummaryList?: StatusSummary[]; + configurationInfos?: ConfigurationInfo[]; + } +} diff --git a/feature-libs/product-configurator/common/core/model/common-configurator.model.ts b/feature-libs/product-configurator/common/core/model/common-configurator.model.ts new file mode 100644 index 00000000000..57433f61281 --- /dev/null +++ b/feature-libs/product-configurator/common/core/model/common-configurator.model.ts @@ -0,0 +1,76 @@ +export namespace CommonConfigurator { + /** + * Specifies the owner of a product configuration + */ + export interface Owner { + /** + * Type of the owner, can be product or document related + */ + type?: OwnerType; + /** + * Specifies an owner uniquely, is used as key in the configuration store + */ + key?: string; + /** + * Business identifier of the owner. + * Can be a product code, a cart entry number, or an order code with order entry number + */ + id?: string; + /** + * Configurator type. Derived from the cxRoute + */ + configuratorType?: string; + } + + export interface ReadConfigurationFromCartEntryParameters { + userId?: string; + cartId?: string; + cartEntryNumber?: string; + owner?: CommonConfigurator.Owner; + } + + export interface ReadConfigurationFromOrderEntryParameters { + userId?: string; + orderId?: string; + orderEntryNumber?: string; + owner?: CommonConfigurator.Owner; + } + /** + * Possible types of owners: Product, cart or order entry + */ + export enum OwnerType { + PRODUCT = 'product', + CART_ENTRY = 'cartEntry', + ORDER_ENTRY = 'orderEntry', + } +} +/** + * Statuses that can occur in the generic configuration + * status summary + */ +export enum OrderEntryStatus { + Success = 'SUCCESS', + Info = 'INFO', + Warning = 'WARNING', + Error = 'ERROR', +} + +/** + * Status Summary + */ +export interface StatusSummary { + numberOfIssues?: number; + status?: OrderEntryStatus; +} + +/** + * Configuration information attached to a cart or order entry. + * Does not reflect the entire configuration but gives only a summary, + * in order to better identify different configurations in a cart or order. + */ +export interface ConfigurationInfo { + configurationLabel?: string; + configurationValue?: string; + configuratorType?: string; + status?: string; +} diff --git a/feature-libs/product-configurator/common/core/model/configurator-product-scope.ts b/feature-libs/product-configurator/common/core/model/configurator-product-scope.ts new file mode 100644 index 00000000000..ca2c5113094 --- /dev/null +++ b/feature-libs/product-configurator/common/core/model/configurator-product-scope.ts @@ -0,0 +1,3 @@ +export enum ConfiguratorProductScope { + CONFIGURATOR = 'configurator', +} diff --git a/feature-libs/product-configurator/common/core/model/index.ts b/feature-libs/product-configurator/common/core/model/index.ts new file mode 100644 index 00000000000..87dae66fe6f --- /dev/null +++ b/feature-libs/product-configurator/common/core/model/index.ts @@ -0,0 +1,4 @@ +export * from './common-configurator.model'; +export * from './configurator-product-scope'; +// Imported for side effects (module augmentation) +import './augmented-core.model'; diff --git a/feature-libs/product-configurator/common/index.ts b/feature-libs/product-configurator/common/index.ts new file mode 100644 index 00000000000..67fea0c0658 --- /dev/null +++ b/feature-libs/product-configurator/common/index.ts @@ -0,0 +1,5 @@ +export * from './common-configurator.module'; +export * from './components/index'; +export * from './core/model/index'; +export * from './occ/index'; +export * from './shared/index'; diff --git a/feature-libs/product-configurator/common/ng-package.json b/feature-libs/product-configurator/common/ng-package.json new file mode 100644 index 00000000000..b5b94bd5c6a --- /dev/null +++ b/feature-libs/product-configurator/common/ng-package.json @@ -0,0 +1,11 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts", + "umdModuleIds": { + "@spartacus/core": "core", + "@spartacus/storefront": "storefront", + "@ng-select/ng-select": "ngSelect" + } + } +} diff --git a/feature-libs/product-configurator/common/occ/common-configurator-occ.module.ts b/feature-libs/product-configurator/common/occ/common-configurator-occ.module.ts new file mode 100644 index 00000000000..f8876505763 --- /dev/null +++ b/feature-libs/product-configurator/common/occ/common-configurator-occ.module.ts @@ -0,0 +1,8 @@ +import { NgModule } from '@angular/core'; +import { provideDefaultConfig } from '@spartacus/core'; +import { defaultOccConfiguratorProductConfig } from './default-occ-configurator-product-config'; + +@NgModule({ + providers: [provideDefaultConfig(defaultOccConfiguratorProductConfig)], +}) +export class CommonConfiguratorOccModule {} diff --git a/feature-libs/product-configurator/common/occ/default-occ-configurator-product-config.ts b/feature-libs/product-configurator/common/occ/default-occ-configurator-product-config.ts new file mode 100644 index 00000000000..57921a22215 --- /dev/null +++ b/feature-libs/product-configurator/common/occ/default-occ-configurator-product-config.ts @@ -0,0 +1,22 @@ +import { OccConfig } from '@spartacus/core'; +import { ConfiguratorProductScope } from '../core/model/configurator-product-scope'; + +export const defaultOccConfiguratorProductConfig: OccConfig = { + backend: { + occ: { + endpoints: { + product: { + configurator: + 'products/${productCode}?fields=code,configurable,configuratorType', + }, + }, + }, + loadingScopes: { + product: { + list: { + include: [ConfiguratorProductScope.CONFIGURATOR], + }, + }, + }, + }, +}; diff --git a/feature-libs/product-configurator/common/occ/index.ts b/feature-libs/product-configurator/common/occ/index.ts new file mode 100644 index 00000000000..5bfec5f1bcc --- /dev/null +++ b/feature-libs/product-configurator/common/occ/index.ts @@ -0,0 +1 @@ +export * from './common-configurator-occ.module'; diff --git a/feature-libs/product-configurator/common/public_api.ts b/feature-libs/product-configurator/common/public_api.ts new file mode 100644 index 00000000000..7a9d912cfa6 --- /dev/null +++ b/feature-libs/product-configurator/common/public_api.ts @@ -0,0 +1,4 @@ +/* + * Public API Surface of common configuration + */ +export * from './index'; diff --git a/feature-libs/product-configurator/common/shared/index.ts b/feature-libs/product-configurator/common/shared/index.ts new file mode 100644 index 00000000000..0a16965d412 --- /dev/null +++ b/feature-libs/product-configurator/common/shared/index.ts @@ -0,0 +1,2 @@ +export * from './testing/index'; +export * from './utils/index'; diff --git a/feature-libs/product-configurator/common/shared/testing/common-configurator-test-utils.service.ts b/feature-libs/product-configurator/common/shared/testing/common-configurator-test-utils.service.ts new file mode 100644 index 00000000000..5ba87e7ebd0 --- /dev/null +++ b/feature-libs/product-configurator/common/shared/testing/common-configurator-test-utils.service.ts @@ -0,0 +1,66 @@ +/** + * Common configurator component test utils service provides helper functions for the component tests. + */ + +export class CommonConfiguratorTestUtilsService { + /** + * Helper function for proving whether the element is present in the DOM tree. + * + * @param expect - Expectation for a spec. + * @param htmlElement - HTML element. + * @param querySelector - Query selector + */ + static expectElementPresent( + expect, + htmlElement: Element, + querySelector: string + ) { + expect(htmlElement.querySelectorAll(querySelector).length).toBeGreaterThan( + 0, + "expected element identified by selector '" + + querySelector + + "' to be present, but it is NOT! innerHtml: " + + htmlElement.innerHTML + ); + } + + /** + * Helper function for proving whether the element contains text. + * + * @param expect - Expectation for a spec. + * @param htmlElement - HTML element. + * @param querySelector - Query selector + * @param expectedText - Expected text + */ + static expectElementToContainText( + expect, + htmlElement: Element, + querySelector: string, + expectedText: string + ) { + expect(htmlElement.querySelector(querySelector).textContent.trim()).toBe( + expectedText + ); + } + + /** + * Helper function for proving whether the element is not present in the DOM tree. + * + * @param expect - Expectation for a spec. + * @param htmlElement - HTML element. + * @param querySelector - Query selector + */ + static expectElementNotPresent( + expect, + htmlElement: Element, + querySelector: string + ) { + expect(htmlElement.querySelectorAll(querySelector).length).toBe( + 0, + "expected element identified by selector '" + + querySelector + + "' to be NOT present, but it is! innerHtml: " + + htmlElement.innerHTML + ); + } +} diff --git a/feature-libs/product-configurator/common/shared/testing/index.ts b/feature-libs/product-configurator/common/shared/testing/index.ts new file mode 100644 index 00000000000..97af92fea49 --- /dev/null +++ b/feature-libs/product-configurator/common/shared/testing/index.ts @@ -0,0 +1 @@ +export * from './common-configurator-test-utils.service'; diff --git a/feature-libs/product-configurator/common/shared/utils/common-configurator-utils.service.spec.ts b/feature-libs/product-configurator/common/shared/utils/common-configurator-utils.service.spec.ts new file mode 100644 index 00000000000..b278a3ccf1c --- /dev/null +++ b/feature-libs/product-configurator/common/shared/utils/common-configurator-utils.service.spec.ts @@ -0,0 +1,177 @@ +import { Type } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Cart, OCC_USER_ID_ANONYMOUS, OrderEntry } from '@spartacus/core'; +import { CommonConfigurator } from '../../core/model/common-configurator.model'; +import { OrderEntryStatus } from './../../core/model/common-configurator.model'; +import { CommonConfiguratorUtilsService } from './common-configurator-utils.service'; + +const productCode = 'CONF_LAPTOP'; +const documentId = '12344'; +const entryNumber = 4; +let owner: CommonConfigurator.Owner = null; + +const CART_CODE = '0000009336'; +const CART_GUID = 'e767605d-7336-48fd-b156-ad50d004ca10'; + +const cart: Cart = { + code: CART_CODE, + guid: CART_GUID, + user: { uid: OCC_USER_ID_ANONYMOUS }, +}; + +let cartItem: OrderEntry; + +describe('CommonConfiguratorUtilsService', () => { + let classUnderTest: CommonConfiguratorUtilsService; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({}).compileComponents(); + }) + ); + beforeEach(() => { + classUnderTest = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + owner = {}; + cartItem = {}; + }); + + it('should create component', () => { + expect(classUnderTest).toBeDefined(); + }); + + it('should set key for product related owner', () => { + owner.type = CommonConfigurator.OwnerType.PRODUCT; + owner.id = productCode; + classUnderTest.setOwnerKey(owner); + expect(owner.key.includes(productCode)).toBe(true); + expect(owner.key.includes(CommonConfigurator.OwnerType.PRODUCT)).toBe(true); + }); + + it('should set key for document related owner', () => { + owner.type = CommonConfigurator.OwnerType.CART_ENTRY; + owner.id = '1'; + classUnderTest.setOwnerKey(owner); + expect(owner.key.includes(owner.id)).toBe(true); + expect(owner.key.includes(CommonConfigurator.OwnerType.CART_ENTRY)).toBe( + true + ); + }); + + it('should throw an error if no owner type is present', () => { + expect(function () { + classUnderTest.setOwnerKey(owner); + }).toThrow(); + }); + + it('should throw an error if for owner type PRODUCT if no product code is present', () => { + owner.type = CommonConfigurator.OwnerType.PRODUCT; + expect(function () { + classUnderTest.setOwnerKey(owner); + }).toThrow(); + }); + + it('should throw an error if for owner type CART_ENTRY no cart entry link is present', () => { + owner.type = CommonConfigurator.OwnerType.CART_ENTRY; + expect(function () { + classUnderTest.setOwnerKey(owner); + }).toThrow(); + }); + + it('should throw an error if for owner type ORDER_ENTRY no order entry link is present', () => { + owner.type = CommonConfigurator.OwnerType.ORDER_ENTRY; + expect(function () { + classUnderTest.setOwnerKey(owner); + }).toThrow(); + }); + + it('should compose an owner ID from 2 attributes', () => { + expect(classUnderTest.getComposedOwnerId(documentId, entryNumber)).toBe( + documentId + '+' + entryNumber + ); + }); + + it('should decompose an owner ID properly', () => { + const decompose = classUnderTest.decomposeOwnerId( + classUnderTest.getComposedOwnerId(documentId, entryNumber) + ); + expect(decompose.documentId).toBe(documentId); + expect(decompose.entryNumber).toBe('' + entryNumber); + }); + + it('should throw an error in case ownerId is malformed', () => { + expect(function () { + classUnderTest.decomposeOwnerId(documentId); + }).toThrow(); + }); + + describe('getCartId', () => { + it('should return cart guid if user is anonymous', () => { + expect(classUnderTest.getCartId(cart)).toBe(CART_GUID); + }); + + it('should return cart code if user is not anonymous', () => { + const namedCart: Cart = { + code: CART_CODE, + guid: CART_GUID, + user: { name: 'Ulf Becker', uid: 'ulf.becker@rustic-hw.com' }, + }; + expect(classUnderTest.getCartId(namedCart)).toBe(CART_CODE); + }); + }); + + describe('getUserId', () => { + it('should return anonymous user id if user is anonymous', () => { + expect(classUnderTest.getUserId(cart)).toBe(OCC_USER_ID_ANONYMOUS); + }); + }); + + describe('Cart item issue handling', () => { + it('should return number of issues of ERROR status', () => { + cartItem.statusSummaryList = [ + { numberOfIssues: 2, status: OrderEntryStatus.Error }, + ]; + expect(classUnderTest.getNumberOfIssues(cartItem)).toBe(2); + }); + + it('should return number of issues of ERROR status if ERROR and SUCCESS statuses are present', () => { + cartItem.statusSummaryList = [ + { numberOfIssues: 1, status: OrderEntryStatus.Success }, + { numberOfIssues: 3, status: OrderEntryStatus.Error }, + ]; + expect(classUnderTest.getNumberOfIssues(cartItem)).toBe(3); + }); + + it('should return number of issues as 0 if only SUCCESS status is present', () => { + cartItem.statusSummaryList = [ + { numberOfIssues: 2, status: OrderEntryStatus.Success }, + ]; + expect(classUnderTest.getNumberOfIssues(cartItem)).toBe(0); + }); + + it('should return number of issues as 0 if statusSummaryList is undefined', () => { + cartItem.statusSummaryList = undefined; + expect(classUnderTest.getNumberOfIssues(cartItem)).toBe(0); + }); + + it('should return number of issues as 0 if statusSummaryList is empty', () => { + cartItem.statusSummaryList = []; + expect(classUnderTest.getNumberOfIssues(cartItem)).toBe(0); + }); + + it('should return true if number of issues of ERROR status is > 0', () => { + cartItem.statusSummaryList = [ + { numberOfIssues: 2, status: OrderEntryStatus.Error }, + ]; + expect(classUnderTest.hasIssues(cartItem)).toBeTrue(); + }); + + it('should return false if number of issues of ERROR status is = 0', () => { + cartItem.statusSummaryList = [ + { numberOfIssues: 2, status: OrderEntryStatus.Success }, + ]; + expect(classUnderTest.hasIssues(cartItem)).toBeFalse(); + }); + }); +}); diff --git a/feature-libs/product-configurator/common/shared/utils/common-configurator-utils.service.ts b/feature-libs/product-configurator/common/shared/utils/common-configurator-utils.service.ts new file mode 100644 index 00000000000..36a7d1db115 --- /dev/null +++ b/feature-libs/product-configurator/common/shared/utils/common-configurator-utils.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@angular/core'; +import { + Cart, + OCC_USER_ID_ANONYMOUS, + OCC_USER_ID_CURRENT, + OrderEntry, +} from '@spartacus/core'; +import { CommonConfigurator } from '../../core/model/common-configurator.model'; +import { OrderEntryStatus } from './../../core/model/common-configurator.model'; + +/** + * Utilities for generic configuration + */ +@Injectable({ providedIn: 'root' }) +export class CommonConfiguratorUtilsService { + /** + * Compiles a unique key for a configuration owner and sets it into the 'key' + * attribute + * @param owner Specifies the owner of a product configuration + */ + public setOwnerKey(owner: CommonConfigurator.Owner) { + if (owner.type === CommonConfigurator.OwnerType.PRODUCT) { + if (!owner.id) { + throw new Error('We expect a product code!'); + } + } else if (owner.type === CommonConfigurator.OwnerType.CART_ENTRY) { + if (!owner.id) { + throw new Error('We expect a document entry Id!'); + } + } else if (owner.type === CommonConfigurator.OwnerType.ORDER_ENTRY) { + if (!owner.id) { + throw new Error('We expect a document entry Id!'); + } + } else { + throw new Error('We expect an owner type!'); + } + owner.key = owner.type + '/' + owner.id; + } + + /** + * Composes owner ID from document ID and entry number + * @param documentId ID of document the entry is part of, like the order or quote code + * @param entryNumber Entry number + * @returns {string} owner ID + */ + public getComposedOwnerId(documentId: string, entryNumber: number): string { + return documentId + '+' + entryNumber; + } + + /** + * Decomposes an owner ID into documentId and entryNumber + * @param ownerId ID of owner + * @returns {any} object containing documentId and entryNumber + */ + public decomposeOwnerId(ownerId: string): any { + const parts: string[] = ownerId.split('+'); + if (parts.length !== 2) { + throw new Error('We only expect 2 parts in ownerId, separated by +'); + } + const result = { documentId: parts[0], entryNumber: parts[1] }; + return result; + } + /** + * Gets cart ID (which can be either its guid or its code) + * @param cart Cart + * @returns Cart identifier + */ + public getCartId(cart: Cart): string { + return cart.user.uid === OCC_USER_ID_ANONYMOUS ? cart.guid : cart.code; + } + /** + * Gets cart user + * @param cart Cart + * @returns User identifier + */ + public getUserId(cart: Cart): string { + return cart.user.uid === OCC_USER_ID_ANONYMOUS + ? cart.user.uid + : OCC_USER_ID_CURRENT; + } + + /** + * Verifies whether the item has any issues. + * + * @param cartItem - Cart item + * @returns {boolean} - whether there are any issues + */ + hasIssues(cartItem: OrderEntry): boolean { + return this.getNumberOfIssues(cartItem) > 0; + } + + /** + * Retrieves the number of issues at the cart item. + * + * @param cartItem - Cart item + * @returns {number} - the number of issues at the cart item + */ + getNumberOfIssues(cartItem: OrderEntry): number { + let numberOfIssues = 0; + cartItem?.statusSummaryList?.forEach((statusSummary) => { + if (statusSummary.status === OrderEntryStatus.Error) { + numberOfIssues = statusSummary.numberOfIssues; + } + }); + return numberOfIssues; + } +} diff --git a/feature-libs/product-configurator/common/shared/utils/index.ts b/feature-libs/product-configurator/common/shared/utils/index.ts new file mode 100644 index 00000000000..26853c7d952 --- /dev/null +++ b/feature-libs/product-configurator/common/shared/utils/index.ts @@ -0,0 +1 @@ +export * from './common-configurator-utils.service'; diff --git a/feature-libs/product-configurator/common/styles/_configurator-issues-notification.scss b/feature-libs/product-configurator/common/styles/_configurator-issues-notification.scss new file mode 100644 index 00000000000..83928f9d04c --- /dev/null +++ b/feature-libs/product-configurator/common/styles/_configurator-issues-notification.scss @@ -0,0 +1,55 @@ +%cx-configurator-issues-notification { + display: none; + + &:not(:empty) { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + background-color: rgba(245, 206, 206, 1); + + padding-inline-start: 5px; + padding-inline-end: 5px; + padding-block-start: 5px; + padding-block-end: 5px; + + margin-block-start: 1.25rem; + margin-block-end: 1.25rem; + + @include media-breakpoint-down(sm) { + margin-inline-start: -45px; + margin-inline-end: -65px; + } + + cx-icon { + align-self: flex-start; + color: var(--cx-color-danger); + font-size: 30px; + padding-inline-start: 15px; + padding-inline-end: 15px; + padding-block-start: 5px; + padding-block-end: 5px; + } + + .cx-error-msg { + word-break: break-word; + padding-inline-end: 15px; + + @include media-breakpoint-down(sm) { + padding-inline-end: 85px; + } + + .cx-error-msg-action { + button.link { + color: var(--cx-color-text); + text-decoration: underline; + + &:hover { + color: var(--cx-color-primary); + text-decoration: none; + } + } + } + } + } +} diff --git a/feature-libs/product-configurator/common/styles/_configure-cart-entry-info.scss b/feature-libs/product-configurator/common/styles/_configure-cart-entry-info.scss new file mode 100644 index 00000000000..4fab0f5609e --- /dev/null +++ b/feature-libs/product-configurator/common/styles/_configure-cart-entry-info.scss @@ -0,0 +1,39 @@ +%cx-configurator-cart-entry-info { + .cx-configuration-info { + display: flex; + + @include media-breakpoint-up(xl) { + flex-direction: row; + } + + @include media-breakpoint-down(xl) { + flex-direction: column; + } + + &:first-of-type { + margin-block-start: 10px; + } + + &:last-of-type { + margin-block-end: 10px; + } + + .cx-label { + @include type('8'); + word-break: break-word; + } + + .cx-value { + @include type('7'); + word-break: break-word; + padding-inline-start: 0px; + } + + @include media-breakpoint-down(xl) { + .cx-label, + .cx-value { + width: 100%; + } + } + } +} diff --git a/feature-libs/product-configurator/common/styles/_configure-cart-entry.scss b/feature-libs/product-configurator/common/styles/_configure-cart-entry.scss new file mode 100644 index 00000000000..0fcc77a2520 --- /dev/null +++ b/feature-libs/product-configurator/common/styles/_configure-cart-entry.scss @@ -0,0 +1,18 @@ +%cx-configure-cart-entry { + a.link, + label.disabled-link { + color: var(--cx-color-primary); + text-decoration: none; + font-size: inherit; + inline-size: max-content; + } + + a.link:hover { + text-decoration: underline; + } + + label.disabled-link { + display: inline-block; + margin: 0; + } +} diff --git a/feature-libs/product-configurator/common/styles/_configure-product.scss b/feature-libs/product-configurator/common/styles/_configure-product.scss new file mode 100644 index 00000000000..ed8f6852424 --- /dev/null +++ b/feature-libs/product-configurator/common/styles/_configure-product.scss @@ -0,0 +1,21 @@ +// Configure button needs a margin enforce a distance to predecessor buttons * +%cx-configure-product { + &:not(:empty) { + .btn-block { + margin-block-start: 5px; + } + } +} + +cx-page-slot { + &.Summary { + %cx-configure-product { + @include media-breakpoint-up(lg) { + grid-column: 2; + padding-inline-start: 20px; + padding-inline-end: 20px; + padding-block-end: 0px; + } + } + } +} diff --git a/feature-libs/product-configurator/common/styles/_index.scss b/feature-libs/product-configurator/common/styles/_index.scss new file mode 100644 index 00000000000..9fb755b8e3d --- /dev/null +++ b/feature-libs/product-configurator/common/styles/_index.scss @@ -0,0 +1,4 @@ +@import 'configure-cart-entry'; +@import 'configure-cart-entry-info'; +@import 'configure-product'; +@import 'configurator-issues-notification'; diff --git a/feature-libs/product-configurator/jest.schematics.config.js b/feature-libs/product-configurator/jest.schematics.config.js new file mode 100644 index 00000000000..40ab645aa35 --- /dev/null +++ b/feature-libs/product-configurator/jest.schematics.config.js @@ -0,0 +1,29 @@ +const { pathsToModuleNameMapper } = require('ts-jest/utils'); +const { compilerOptions } = require('./tsconfig.schematics'); + +module.exports = { + setupFilesAfterEnv: ['/jest.ts'], + transform: { + '^.+\\.ts?$': 'ts-jest', + }, + + collectCoverage: false, + coverageReporters: ['json', 'lcov', 'text', 'clover'], + coverageDirectory: '/../../coverage/storefinder/schematics', + coverageThreshold: { + global: { + branches: 70, + functions: 80, + lines: 80, + statements: 80, + }, + }, + + roots: ['/schematics'], + modulePaths: ['/../../projects/schematics'], + testMatch: ['**/+(*_)+(spec).+(ts)'], + moduleFileExtensions: ['js', 'ts', 'json'], + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths || {}, { + prefix: '/', + }), +}; diff --git a/feature-libs/product-configurator/jest.ts b/feature-libs/product-configurator/jest.ts new file mode 100644 index 00000000000..fa0c277b54a --- /dev/null +++ b/feature-libs/product-configurator/jest.ts @@ -0,0 +1,24 @@ +// uncomment when we switch the whole lib to jest +/** +Object.defineProperty(window, 'CSS', { value: null }); +Object.defineProperty(window, 'getComputedStyle', { + value: () => { + return { + display: 'none', + appearance: ['-webkit-appearance'], + }; + }, +}); + +Object.defineProperty(document, 'doctype', { + value: '', +}); +Object.defineProperty(document.body.style, 'transform', { + value: () => { + return { + enumerable: true, + configurable: true, + }; + }, +}); +*/ diff --git a/feature-libs/product-configurator/karma.conf.js b/feature-libs/product-configurator/karma.conf.js new file mode 100644 index 00000000000..5a528346211 --- /dev/null +++ b/feature-libs/product-configurator/karma.conf.js @@ -0,0 +1,46 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-coverage'), + require('karma-junit-reporter'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage-istanbul-reporter'), + require('@angular-devkit/build-angular/plugins/karma'), + ], + client: { + clearContext: false, // leave Jasmine Spec Runner output visible in browser + jasmine: { + random: false, + }, + }, + reporters: ['progress', 'kjhtml', 'coverage-istanbul', 'dots'], + coverageIstanbulReporter: { + dir: require('path').join( + __dirname, + '../../coverage/product-configurator' + ), + reports: ['lcov', 'cobertura', 'text-summary'], + fixWebpackSourcePaths: true, + thresholds: { + statements: 80, + lines: 80, + branches: 70, + functions: 80, + }, + }, + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false, + restartOnFileChange: true, + }); +}; diff --git a/feature-libs/product-configurator/ng-package.json b/feature-libs/product-configurator/ng-package.json new file mode 100644 index 00000000000..105f689b706 --- /dev/null +++ b/feature-libs/product-configurator/ng-package.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/product-configurator", + "lib": { + "entryFile": "./public_api.ts", + "umdModuleIds": { + "@spartacus/core": "core", + "@spartacus/storefront": "storefront", + "rxjs": "rxjs" + } + }, + "assets": ["**/*.scss", "schematics/**/*.json", "schematics/**/*.js"] +} diff --git a/feature-libs/product-configurator/package.json b/feature-libs/product-configurator/package.json new file mode 100644 index 00000000000..7590b972aa1 --- /dev/null +++ b/feature-libs/product-configurator/package.json @@ -0,0 +1,42 @@ +{ + "name": "@spartacus/product-configurator", + "version": "3.0.0-next.6", + "description": "Product configurator feature library for Spartacus", + "keywords": [ + "spartacus", + "framework", + "storefront", + "product configurator" + ], + "homepage": "https://github.com/SAP/spartacus", + "repository": "https://github.com/SAP/spartacus/tree/develop/feature-libs/product-configurator", + "license": "Apache-2.0", + "scripts": { + "build:schematics": "yarn clean:schematics && ../../node_modules/.bin/tsc -p ./tsconfig.schematics.json", + "clean:schematics": "../../node_modules/.bin/rimraf \"schematics/**/*.js\" \"schematics/**/*.js.map\" \"schematics/**/*.d.ts\"", + "test:schematics": "yarn --cwd ../../projects/schematics/ run clean && yarn clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" + }, + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular-devkit/schematics": "^10.2.1", + "@angular/common": "^10.2.4", + "@angular/core": "^10.2.4", + "@angular/forms": "^10.2.4", + "@angular/router": "^10.2.4", + "@ng-select/ng-select": "^5.0.9", + "@ngrx/effects": "^10.0.0", + "@ngrx/store": "^10.0.0", + "@schematics/angular": "10.2.1", + "@spartacus/core": "3.0.0", + "@spartacus/schematics": "3.0.0", + "@spartacus/storefront": "3.0.0", + "@spartacus/styles": "3.0.0", + "rxjs": "^6.6.0" + }, + "publishConfig": { + "access": "public" + }, + "schematics": "./schematics/collection.json" +} diff --git a/feature-libs/product-configurator/public_api.ts b/feature-libs/product-configurator/public_api.ts new file mode 100644 index 00000000000..257546d38d6 --- /dev/null +++ b/feature-libs/product-configurator/public_api.ts @@ -0,0 +1,4 @@ +/* + * Public API Surface of product configuration + */ +export {}; diff --git a/feature-libs/product-configurator/rulebased/_index.scss b/feature-libs/product-configurator/rulebased/_index.scss new file mode 100644 index 00000000000..9a2eac25bd2 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/_index.scss @@ -0,0 +1,43 @@ +@import './styles/index'; + +$configurator-rulebased-components: cx-configurator-template, + cx-configurator-attribute-header, cx-configurator-form, + cx-configurator-attribute-type, cx-required-error-msg, + cx-configurator-footer-container, cx-configurator-footer-container-item, + cx-configurator-form-group, cx-configurator-attribute-radio-button, + cx-configurator-attribute-footer, cx-configurator-attribute-drop-down, + cx-configurator-attribute-read-only, cx-configurator-previous-next-buttons, + cx-configurator-overview-form, cx-configurator-overview-attribute, + cx-configurator-group-menu, cx-configurator-price-summary, + cx-configurator-add-to-cart-button, cx-configurator-attribute-input-field, + cx-configurator-attribute-numeric-input-field, cx-configurator-tab-bar, + cx-configurator-textfield-add-to-cart-button, cx-configurator-group-title, + cx-configurator-attribute-checkbox, cx-configurator-attribute-checkbox-list, + cx-configurator-attribute-selection-image, + cx-configurator-attribute-multi-selection-image, + cx-configurator-attribute-single-selection-image, + cx-configurator-update-message, cx-configurator-product-title, + cx-configurator-conflict-suggestion, cx-configurator-conflict-description, + cx-configurator-overview-notification-banner !default; + +$configurator-rulebased-pages: VariantConfigurationTemplate, + VariantConfigurationOverviewTemplate !default; + +@each $selector in $configurator-rulebased-components { + #{$selector} { + @extend %#{$selector} !optional; + } +} + +// add body specific selectors +body { + @each $selector in $configurator-rulebased-components { + @extend %#{$selector}__body !optional; + } +} + +@each $selector in $configurator-rulebased-pages { + cx-page-layout.#{$selector} { + @extend %#{$selector} !optional; + } +} diff --git a/feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.component.html b/feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.component.html new file mode 100644 index 00000000000..de3c6f8015c --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.component.html @@ -0,0 +1,15 @@ + + +
+ +
+
+
diff --git a/feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.component.spec.ts b/feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.component.spec.ts new file mode 100644 index 00000000000..7e574fec67d --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.component.spec.ts @@ -0,0 +1,352 @@ +import { ChangeDetectionStrategy, Type } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + GlobalMessageService, + I18nTestingModule, + RouterState, + RoutingService, +} from '@spartacus/core'; +import { + CommonConfigurator, + ConfiguratorRouter, +} from '@spartacus/product-configurator/common'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorCartService } from '../../core/facade/configurator-cart.service'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { ConfiguratorGroupsService } from '../../core/facade/configurator-groups.service'; +import { Configurator } from '../../core/model/configurator.model'; +import * as ConfigurationTestData from '../../shared/testing/configurator-test-data'; +import { ConfiguratorAddToCartButtonComponent } from './configurator-add-to-cart-button.component'; + +const CART_ENTRY_KEY = '1'; +const configuratorType = 'cpqconfigurator'; + +const ROUTE_OVERVIEW = 'configureOverviewCPQCONFIGURATOR'; + +const mockProductConfiguration = ConfigurationTestData.productConfiguration; + +const navParamsOverview: any = { + cxRoute: 'configureOverview' + configuratorType, + params: { ownerType: 'cartEntry', entityKey: CART_ENTRY_KEY }, +}; + +const attributes = {}; + +const mockRouterData: ConfiguratorRouter.Data = { + pageType: ConfiguratorRouter.PageType.CONFIGURATION, + isOwnerCartEntry: false, + owner: mockProductConfiguration.owner, +}; + +let component: ConfiguratorAddToCartButtonComponent; +let fixture: ComponentFixture; +let htmlElem: HTMLElement; +let routerStateObservable = null; +let productConfigurationObservable = null; +let pendingChangesObservable = null; + +function initialize() { + routerStateObservable = of(mockRouterState); + productConfigurationObservable = of(mockProductConfiguration); + fixture = TestBed.createComponent(ConfiguratorAddToCartButtonComponent); + component = fixture.componentInstance; + htmlElem = fixture.nativeElement; + fixture.detectChanges(); +} + +class MockGlobalMessageService { + add(): void {} +} + +class MockConfiguratorCommonsService { + getConfiguration(): Observable { + return productConfigurationObservable; + } + removeConfiguration() {} + removeUiState() {} + hasPendingChanges() { + return pendingChangesObservable; + } +} + +class MockConfiguratorCartService { + updateCartEntry() {} + addToCart() {} +} + +class MockConfiguratorGroupsService { + setGroupStatusVisited() {} +} + +function setRouterTestDataCartBoundAndConfigPage() { + mockRouterState.state.params = { + entityKey: CART_ENTRY_KEY, + ownerType: CommonConfigurator.OwnerType.CART_ENTRY, + }; + mockRouterState.state.semanticRoute = ROUTE_CONFIGURATION; + mockRouterData.isOwnerCartEntry = true; + mockRouterData.owner.type = CommonConfigurator.OwnerType.CART_ENTRY; + mockRouterData.owner.id = CART_ENTRY_KEY; + mockRouterData.pageType = ConfiguratorRouter.PageType.CONFIGURATION; +} + +function setRouterTestDataProductBoundAndConfigPage() { + mockRouterState.state.params = { + entityKey: ConfigurationTestData.PRODUCT_CODE, + ownerType: CommonConfigurator.OwnerType.PRODUCT, + }; + mockRouterState.state.semanticRoute = ROUTE_CONFIGURATION; + mockRouterData.isOwnerCartEntry = false; + mockRouterData.owner.type = CommonConfigurator.OwnerType.PRODUCT; + mockRouterData.owner.id = ConfigurationTestData.PRODUCT_CODE; + mockRouterData.pageType = ConfiguratorRouter.PageType.CONFIGURATION; +} + +function performAddToCartOnOverview() { + setRouterTestDataProductBoundAndConfigPage(); + mockRouterState.state.semanticRoute = ROUTE_OVERVIEW; + mockRouterData.pageType = ConfiguratorRouter.PageType.OVERVIEW; + initialize(); + component.onAddToCart(mockProductConfiguration, mockRouterData); +} + +function performUpdateCart() { + ensureCartBound(); + component.onAddToCart(mockProductConfiguration, mockRouterData); +} + +function ensureCartBound() { + setRouterTestDataCartBoundAndConfigPage(); + mockProductConfiguration.owner.id = CART_ENTRY_KEY; + initialize(); +} + +function ensureCartBoundAndOnOverview() { + setRouterTestDataCartBoundAndConfigPage(); + mockRouterState.state.semanticRoute = ROUTE_OVERVIEW; + mockRouterData.pageType = ConfiguratorRouter.PageType.OVERVIEW; + initialize(); +} + +function ensureProductBound() { + setRouterTestDataProductBoundAndConfigPage(); + mockProductConfiguration.nextOwner.id = CART_ENTRY_KEY; + initialize(); +} + +function performUpdateOnOV() { + ensureCartBoundAndOnOverview(); + component.onAddToCart(mockProductConfiguration, mockRouterData); +} +const ROUTE_CONFIGURATION = 'configureCPQCONFIGURATOR'; +const mockRouterState: any = { + state: { + semanticRoute: ROUTE_CONFIGURATION, + params: { + entityKey: ConfigurationTestData.PRODUCT_CODE, + ownerType: CommonConfigurator.OwnerType.PRODUCT, + }, + queryParams: {}, + }, +}; +class MockRoutingService { + getRouterState(): Observable { + return routerStateObservable; + } + go() {} +} + +describe('ConfigAddToCartButtonComponent', () => { + let routingService: RoutingService; + let globalMessageService: GlobalMessageService; + let configuratorCommonsService: ConfiguratorCommonsService; + let configuratorGroupsService: ConfiguratorGroupsService; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [I18nTestingModule], + declarations: [ConfiguratorAddToCartButtonComponent], + providers: [ + { + provide: RoutingService, + useClass: MockRoutingService, + }, + { + provide: ConfiguratorCommonsService, + useClass: MockConfiguratorCommonsService, + }, + { + provide: ConfiguratorCartService, + useClass: MockConfiguratorCartService, + }, + { + provide: ConfiguratorGroupsService, + useClass: MockConfiguratorGroupsService, + }, + { provide: GlobalMessageService, useClass: MockGlobalMessageService }, + ], + }) + .overrideComponent(ConfiguratorAddToCartButtonComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + routerStateObservable = null; + productConfigurationObservable = null; + pendingChangesObservable = of(false); + initialize(); + routingService = TestBed.inject(RoutingService as Type); + configuratorCommonsService = TestBed.inject( + ConfiguratorCommonsService as Type + ); + globalMessageService = TestBed.inject( + GlobalMessageService as Type + ); + configuratorGroupsService = TestBed.inject( + ConfiguratorGroupsService as Type + ); + spyOn(configuratorGroupsService, 'setGroupStatusVisited').and.callThrough(); + spyOn(routingService, 'go').and.callThrough(); + spyOn(globalMessageService, 'add').and.callThrough(); + spyOn(configuratorCommonsService, 'removeConfiguration').and.callThrough(); + }); + + it('should create', () => { + initialize(); + expect(component).toBeTruthy(); + }); + + it('should render button that is not disabled in case there are no pending changes', () => { + initialize(); + expect(htmlElem.querySelector('button').disabled).toBe(false); + }); + + it('should not disable button in case there are pending changes', () => { + pendingChangesObservable = of(true); + initialize(); + expect(htmlElem.querySelector('button').disabled).toBe(false); + }); + + describe('onAddToCart', () => { + it('should navigate to OV in case configuration is cart bound and we are on product config page', () => { + mockRouterData.pageType = ConfiguratorRouter.PageType.CONFIGURATION; + performUpdateCart(); + expect(routingService.go).toHaveBeenCalledWith( + navParamsOverview, + attributes + ); + + expect( + configuratorGroupsService.setGroupStatusVisited + ).toHaveBeenCalled(); + }); + + it('should navigate to cart in case configuration is cart bound and we are on OV config page', () => { + performUpdateOnOV(); + expect(routingService.go).toHaveBeenCalledWith('cart'); + }); + + it('should not remove configuration for cart entry owner in case configuration is cart bound and we are on OV page and no changes happened', () => { + mockProductConfiguration.isCartEntryUpdateRequired = false; + performUpdateOnOV(); + expect( + configuratorCommonsService.removeConfiguration + ).toHaveBeenCalledTimes(0); + }); + + it('should not remove configuration and display no message in case continue to cart is triggered on config page', () => { + mockProductConfiguration.isCartEntryUpdateRequired = false; + mockRouterData.pageType = ConfiguratorRouter.PageType.CONFIGURATION; + performUpdateCart(); + expect( + configuratorCommonsService.removeConfiguration + ).toHaveBeenCalledTimes(0); + expect(globalMessageService.add).toHaveBeenCalledTimes(0); + }); + + it('should display a message in case done is triggered on config page which means that there are pending changes', () => { + mockProductConfiguration.isCartEntryUpdateRequired = true; + performUpdateCart(); + expect(globalMessageService.add).toHaveBeenCalledTimes(1); + }); + + it('should display updateCart message if configuration has already been added', () => { + ensureCartBound(); + mockProductConfiguration.isCartEntryUpdateRequired = true; + component.onAddToCart(mockProductConfiguration, mockRouterData); + expect(globalMessageService.add).toHaveBeenCalledTimes(1); + }); + + it('should navigate to overview in case configuration has not been added yet and we are on configuration page', () => { + ensureProductBound(); + component.onAddToCart(mockProductConfiguration, mockRouterData); + expect(routingService.go).toHaveBeenCalledWith( + navParamsOverview, + attributes + ); + }); + + it('should remove 2 configurations in case configuration has not yet been added and we are on configuration page', () => { + ensureProductBound(); + component.onAddToCart(mockProductConfiguration, mockRouterData); + expect( + configuratorCommonsService.removeConfiguration + ).toHaveBeenCalledTimes(2); + }); + + it('should display addToCart message in case configuration has not been added yet', () => { + ensureProductBound(); + component.onAddToCart(mockProductConfiguration, mockRouterData); + expect(globalMessageService.add).toHaveBeenCalledTimes(1); + }); + + it('should not display addToCart message in case configuration has not been added yet but there are pending changes', () => { + pendingChangesObservable = of(true); + ensureProductBound(); + component.onAddToCart(mockProductConfiguration, mockRouterData); + expect(globalMessageService.add).toHaveBeenCalledTimes(0); + }); + + it('should navigate to cart in case configuration has not yet been added and process was triggered from overview', () => { + mockRouterData.pageType = ConfiguratorRouter.PageType.OVERVIEW; + performAddToCartOnOverview(); + expect(routingService.go).toHaveBeenCalledWith('cart'); + }); + + it('should remove 2 configurations in case configuration has not yet been added and process was triggered from overview', () => { + performAddToCartOnOverview(); + expect( + configuratorCommonsService.removeConfiguration + ).toHaveBeenCalledTimes(2); + }); + }); + + describe('performNavigation', () => { + it('should display message on addToCart ', () => { + component.performNavigation( + configuratorType, + mockProductConfiguration.owner, + true, + true, + true + ); + expect(globalMessageService.add).toHaveBeenCalledTimes(1); + }); + it('should display no message on addToCart in case this is not desired', () => { + component.performNavigation( + configuratorType, + mockProductConfiguration.owner, + true, + true, + false + ); + expect(globalMessageService.add).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.component.ts b/feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.component.ts new file mode 100644 index 00000000000..078748561a0 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.component.ts @@ -0,0 +1,218 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + GlobalMessageService, + GlobalMessageType, + RoutingService, +} from '@spartacus/core'; +import { + CommonConfigurator, + ConfiguratorRouter, + ConfiguratorRouterExtractorService, +} from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { filter, map, switchMap, take } from 'rxjs/operators'; +import { ConfiguratorCartService } from '../../core/facade/configurator-cart.service'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { ConfiguratorGroupsService } from '../../core/facade/configurator-groups.service'; +import { Configurator } from '../../core/model/configurator.model'; + +@Component({ + selector: 'cx-configurator-add-to-cart-button', + templateUrl: './configurator-add-to-cart-button.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorAddToCartButtonComponent { + container$: Observable<{ + routerData: ConfiguratorRouter.Data; + configuration: Configurator.Configuration; + hasPendingChanges: boolean; + }> = this.configRouterExtractorService.extractRouterData().pipe( + switchMap((routerData) => + this.configuratorCommonsService + .getConfiguration(routerData.owner) + .pipe(map((configuration) => ({ routerData, configuration }))) + .pipe( + switchMap((cont) => + this.configuratorCommonsService + .hasPendingChanges(cont.configuration.owner) + .pipe( + map((hasPendingChanges) => ({ + routerData: cont.routerData, + configuration: cont.configuration, + hasPendingChanges, + })) + ) + ) + ) + ) + ); + + constructor( + protected routingService: RoutingService, + protected configuratorCommonsService: ConfiguratorCommonsService, + protected configuratorCartService: ConfiguratorCartService, + protected configuratorGroupsService: ConfiguratorGroupsService, + protected configRouterExtractorService: ConfiguratorRouterExtractorService, + protected globalMessageService: GlobalMessageService + ) {} + + protected navigateToCart(): void { + this.routingService.go('cart'); + } + + protected navigateToOverview( + configuratorType: string, + owner: CommonConfigurator.Owner + ): void { + this.routingService.go( + { + cxRoute: 'configureOverview' + configuratorType, + params: { ownerType: 'cartEntry', entityKey: owner.id }, + }, + {} + ); + } + + protected displayConfirmationMessage(key: string): void { + this.globalMessageService.add( + { key: key }, + GlobalMessageType.MSG_TYPE_CONFIRMATION + ); + } + + /** + * Performs the navigation to the corresponding location (cart or overview pages). + * + * @param {string} configuratorType - Configurator type + * @param {CommonConfigurator.Owner} owner - Owner + * @param {boolean} isAdd - Is add to cart + * @param {boolean} isOverview - Is overview page + * @param {boolean} showMessage - Show message + */ + performNavigation( + configuratorType: string, + owner: CommonConfigurator.Owner, + isAdd: boolean, + isOverview: boolean, + showMessage: boolean + ): void { + const messageKey = isAdd + ? 'configurator.addToCart.confirmation' + : 'configurator.addToCart.confirmationUpdate'; + if (isOverview) { + this.navigateToCart(); + } else { + this.navigateToOverview(configuratorType, owner); + } + if (showMessage) { + this.displayConfirmationMessage(messageKey); + } + } + + /** + * Decides on the resource key for the button. Depending on the business process (owner of the configuration) and the + * need for a cart update, the text will differ + * @param {ConfiguratorRouter.Data} routerData - Reflects the current router state + * @param {Configurator.Configuration} configuration - Configuration + * @returns {string} The resource key that controls the button description + */ + getButtonResourceKey( + routerData: ConfiguratorRouter.Data, + configuration: Configurator.Configuration + ): string { + if ( + routerData.isOwnerCartEntry && + configuration.isCartEntryUpdateRequired + ) { + return 'configurator.addToCart.buttonUpdateCart'; + } else if ( + routerData.isOwnerCartEntry && + !configuration.isCartEntryUpdateRequired + ) { + return 'configurator.addToCart.buttonAfterAddToCart'; + } else { + return 'configurator.addToCart.button'; + } + } + + /** + * Triggers action and navigation, both depending on the context. Might result in an addToCart, updateCartEntry, + * just a cart navigation or a browser back navigation + * @param {Configurator.Configuration} configuration - Configuration + * @param {ConfiguratorRouter.Data} routerData - Reflects the current router state + + */ + onAddToCart( + configuration: Configurator.Configuration, + routerData: ConfiguratorRouter.Data + ): void { + const pageType = routerData.pageType; + const configuratorType = configuration.owner.configuratorType; + const isOverview = pageType === ConfiguratorRouter.PageType.OVERVIEW; + const isOwnerCartEntry = + routerData.owner.type === CommonConfigurator.OwnerType.CART_ENTRY; + const owner = configuration.owner; + + this.configuratorGroupsService.setGroupStatusVisited( + configuration.owner, + configuration.interactionState.currentGroup + ); + + this.container$ + .pipe( + filter((cont) => !cont.hasPendingChanges), + take(1) + ) + .subscribe(() => { + if (isOwnerCartEntry) { + if (configuration.isCartEntryUpdateRequired) { + this.configuratorCartService.updateCartEntry(configuration); + } + + this.performNavigation( + configuratorType, + owner, + false, + isOverview, + configuration.isCartEntryUpdateRequired + ); + if (configuration.isCartEntryUpdateRequired) { + this.configuratorCommonsService.removeConfiguration(owner); + } + } else { + this.configuratorCartService.addToCart( + owner.id, + configuration.configId, + owner + ); + + this.configuratorCommonsService + .getConfiguration(owner) + .pipe( + filter( + (configWithNextOwner) => + configWithNextOwner.nextOwner !== undefined + ), + take(1) + ) + .subscribe((configWithNextOwner) => { + this.performNavigation( + configuratorType, + configWithNextOwner.nextOwner, + true, + isOverview, + true + ); + // we clean up both the product related configuration (no longer needed) + // and the cart entry related configuration, as we might have a configuration + // for the same cart entry number stored already. + // (Cart entries might have been deleted) + this.configuratorCommonsService.removeConfiguration(owner); + this.configuratorCommonsService.removeConfiguration( + configWithNextOwner.nextOwner + ); + }); + } + }); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.module.ts b/feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.module.ts new file mode 100644 index 00000000000..cac7cc90f56 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/add-to-cart-button/configurator-add-to-cart-button.module.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { CmsConfig, I18nModule, provideDefaultConfig } from '@spartacus/core'; +import { ConfiguratorAddToCartButtonComponent } from './configurator-add-to-cart-button.component'; + +@NgModule({ + imports: [CommonModule, I18nModule], + providers: [ + provideDefaultConfig({ + cmsComponents: { + ConfiguratorAddToCartButton: { + component: ConfiguratorAddToCartButtonComponent, + }, + }, + }), + ], + declarations: [ConfiguratorAddToCartButtonComponent], + exports: [ConfiguratorAddToCartButtonComponent], + entryComponents: [ConfiguratorAddToCartButtonComponent], +}) +export class ConfiguratorAddToCartButtonModule {} diff --git a/feature-libs/product-configurator/rulebased/components/add-to-cart-button/index.ts b/feature-libs/product-configurator/rulebased/components/add-to-cart-button/index.ts new file mode 100644 index 00000000000..6b45ad223e1 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/add-to-cart-button/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-add-to-cart-button.component'; +export * from './configurator-add-to-cart-button.module'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.component.html b/feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.component.html new file mode 100644 index 00000000000..1c1b6000fcd --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.component.html @@ -0,0 +1,7 @@ +
+ + {{ 'configurator.attribute.defaultRequiredMessage' | cxTranslate }} +
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.component.spec.ts new file mode 100644 index 00000000000..99269da44de --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.component.spec.ts @@ -0,0 +1,177 @@ +import { ChangeDetectionStrategy } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { I18nTestingModule } from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { + IconLoaderService, + IconModule, + ICON_TYPE, +} from '@spartacus/storefront'; +import { Observable, of } from 'rxjs'; +import { CommonConfiguratorTestUtilsService } from '../../../../common/shared/testing/common-configurator-test-utils.service'; +import { Configurator } from '../../../core/model/configurator.model'; +import { ConfiguratorStorefrontUtilsService } from '../../service/configurator-storefront-utils.service'; +import { ConfiguratorAttributeFooterComponent } from './configurator-attribute-footer.component'; + +export 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 {} +} + +const isCartEntryOrGroupVisited = true; +class MockConfigUtilsService { + isCartEntryOrGroupVisited(): Observable { + return of(isCartEntryOrGroupVisited); + } +} + +const attributeName = '123'; +const attrLabel = 'attLabel'; +describe('ConfigAttributeFooterComponent', () => { + let classUnderTest: ConfiguratorAttributeFooterComponent; + let fixture: ComponentFixture; + + const currentAttribute: Configurator.Attribute = { + name: attributeName, + label: attrLabel, + uiType: Configurator.UiType.RADIOBUTTON, + }; + let htmlElem: HTMLElement; + + const owner: CommonConfigurator.Owner = { + id: 'PRODUCT_CODE', + type: CommonConfigurator.OwnerType.CART_ENTRY, + }; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [I18nTestingModule, IconModule], + declarations: [ConfiguratorAttributeFooterComponent], + providers: [ + { provide: IconLoaderService, useClass: MockIconFontLoaderService }, + { + provide: ConfiguratorStorefrontUtilsService, + useClass: MockConfigUtilsService, + }, + ], + }) + .overrideComponent(ConfiguratorAttributeFooterComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorAttributeFooterComponent); + classUnderTest = fixture.componentInstance; + htmlElem = fixture.nativeElement; + classUnderTest.attribute = currentAttribute; + + classUnderTest.owner = owner; + classUnderTest.groupId = 'testGroup'; + classUnderTest.attribute.required = true; + classUnderTest.attribute.incomplete = true; + classUnderTest.attribute.uiType = Configurator.UiType.STRING; + classUnderTest.attribute.userInput = ''; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(classUnderTest).toBeTruthy(); + }); + + it('should render a required message if attribute has no value, yet.', () => { + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-required-error-msg' + ); + }); + + it('should render a required message because the group has already been visited.', () => { + classUnderTest.owner.type = CommonConfigurator.OwnerType.PRODUCT; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-required-error-msg' + ); + }); + + it('should render a required message because user input is an empty string.', () => { + currentAttribute.userInput = ' '; + classUnderTest.ngOnInit(); + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-required-error-msg' + ); + }); + + it("shouldn't render a required message if attribute is not required.", () => { + currentAttribute.required = false; + classUnderTest.ngOnInit(); + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + 'cx-required-error-msg' + ); + }); + + it("shouldn't render a required message because user input is set.", () => { + currentAttribute.userInput = 'test'; + classUnderTest.ngOnInit(); + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + 'cx-required-error-msg' + ); + }); + + describe('isUserInputEmpty()', () => { + it('should return false because user input is undefined', () => { + currentAttribute.userInput = undefined; + expect( + classUnderTest.isUserInputEmpty(classUnderTest.attribute.userInput) + ).toBe(false); + }); + + it('should return true because user input contains a number of whitespaces', () => { + currentAttribute.userInput = ' '; + expect( + classUnderTest.isUserInputEmpty(classUnderTest.attribute.userInput) + ).toBe(true); + }); + + it('should return true because user input contains an empty string', () => { + currentAttribute.userInput = ''; + expect( + classUnderTest.isUserInputEmpty(classUnderTest.attribute.userInput) + ).toBe(true); + }); + + it('should return false because user input is defined and contains a string', () => { + currentAttribute.userInput = 'user input string'; + expect( + classUnderTest.isUserInputEmpty(classUnderTest.attribute.userInput) + ).toBe(false); + }); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.component.ts new file mode 100644 index 00000000000..04449cf8263 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.component.ts @@ -0,0 +1,55 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + OnInit, +} from '@angular/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { ICON_TYPE } from '@spartacus/storefront'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Configurator } from '../../../core/model/configurator.model'; +import { ConfiguratorStorefrontUtilsService } from '../../service/configurator-storefront-utils.service'; + +@Component({ + selector: 'cx-configurator-attribute-footer', + templateUrl: './configurator-attribute-footer.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorAttributeFooterComponent implements OnInit { + @Input() attribute: Configurator.Attribute; + @Input() owner: CommonConfigurator.Owner; + @Input() groupId: string; + + constructor(protected configUtils: ConfiguratorStorefrontUtilsService) {} + + iconType = ICON_TYPE; + showRequiredMessageForUserInput$: Observable; + + ngOnInit(): void { + /** + * Show message that indicates that attribute is required in case attribute is a + * free input field + */ + this.showRequiredMessageForUserInput$ = this.configUtils + .isCartEntryOrGroupVisited(this.owner, this.groupId) + .pipe(map((result) => (result ? this.needsUserInputMessage() : false))); + } + + /** + * Checks if attribute is a user input typed attribute with empty value. + * Method will return false for domain based attributes + * @param input + */ + isUserInputEmpty(input: string): boolean { + return input !== undefined && (!input.trim() || 0 === input.length); + } + + protected needsUserInputMessage(): boolean { + return ( + this.attribute.required && + this.attribute.incomplete && + this.isUserInputEmpty(this.attribute.userInput) + ); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.module.ts b/feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.module.ts new file mode 100644 index 00000000000..03c57a19e3a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/footer/configurator-attribute-footer.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { I18nModule } from '@spartacus/core'; +import { IconModule } from '@spartacus/storefront'; +import { ConfiguratorAttributeFooterComponent } from './configurator-attribute-footer.component'; + +@NgModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + CommonModule, + I18nModule, + IconModule, + ], + declarations: [ConfiguratorAttributeFooterComponent], + exports: [ConfiguratorAttributeFooterComponent], + entryComponents: [ConfiguratorAttributeFooterComponent], +}) +export class ConfiguratorAttributeFooterModule {} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/footer/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/footer/index.ts new file mode 100644 index 00000000000..671d2ee3241 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/footer/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-attribute-footer.component'; +export * from './configurator-attribute-footer.module'; 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 new file mode 100644 index 00000000000..f1a1dcd4ed4 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.html @@ -0,0 +1,34 @@ + +
+ +
+ {{ getConflictMessageKey(groupType) | cxTranslate }} +
+
+
+ + {{ getRequiredMessageKey() | cxTranslate }} +
+{{ attribute.images[0].altText }} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.spec.ts new file mode 100644 index 00000000000..c5675a475d8 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.spec.ts @@ -0,0 +1,345 @@ +import { ChangeDetectionStrategy } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { I18nTestingModule } from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { + IconLoaderService, + IconModule, + ICON_TYPE, +} from '@spartacus/storefront'; +import { Observable, of } from 'rxjs'; +import { CommonConfiguratorTestUtilsService } from '../../../../common/shared/testing/common-configurator-test-utils.service'; +import { Configurator } from '../../../core/model/configurator.model'; +import { ConfiguratorStorefrontUtilsService } from '../../service/configurator-storefront-utils.service'; +import { ConfiguratorAttributeHeaderComponent } from './configurator-attribute-header.component'; + +export 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 {} +} + +let isCartEntryOrGroupVisited = true; +class MockConfigUtilsService { + isCartEntryOrGroupVisited(): Observable { + return of(isCartEntryOrGroupVisited); + } +} + +describe('ConfigAttributeHeaderComponent', () => { + let classUnderTest: ConfiguratorAttributeHeaderComponent; + let fixture: ComponentFixture; + + const owner: CommonConfigurator.Owner = { + id: 'PRODUCT_CODE', + type: CommonConfigurator.OwnerType.CART_ENTRY, + }; + + const currentAttribute: Configurator.Attribute = { + name: 'attributeId', + uiType: Configurator.UiType.RADIOBUTTON, + images: [ + { + url: 'someImageURL', + }, + ], + }; + let htmlElem: HTMLElement; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [I18nTestingModule, IconModule], + declarations: [ConfiguratorAttributeHeaderComponent], + providers: [ + { provide: IconLoaderService, useClass: MockIconFontLoaderService }, + { + provide: ConfiguratorStorefrontUtilsService, + useClass: MockConfigUtilsService, + }, + ], + }) + .overrideComponent(ConfiguratorAttributeHeaderComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorAttributeHeaderComponent); + classUnderTest = fixture.componentInstance; + htmlElem = fixture.nativeElement; + classUnderTest.attribute = currentAttribute; + classUnderTest.attribute.label = 'label of attribute'; + classUnderTest.attribute.name = '123'; + classUnderTest.owner = owner; + classUnderTest.groupId = 'testGroup'; + classUnderTest.attribute.required = false; + classUnderTest.attribute.incomplete = true; + classUnderTest.attribute.uiType = Configurator.UiType.RADIOBUTTON; + classUnderTest.groupType = Configurator.GroupType.ATTRIBUTE_GROUP; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(classUnderTest).toBeTruthy(); + }); + + describe('Render corresponding part of the component', () => { + it('should render a label', () => { + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'label' + ); + CommonConfiguratorTestUtilsService.expectElementToContainText( + expect, + htmlElem, + 'label', + 'label of attribute' + ); + const id = htmlElem.querySelector('label').getAttribute('id'); + expect(id.indexOf('123')).toBeGreaterThan( + 0, + 'id of label does not contain the StdAttrCode' + ); + expect( + htmlElem.querySelector('label').getAttribute('aria-label') + ).toEqual(classUnderTest.attribute.label); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + '.cx-required-icon' + ); + }); + + it('should render a label as required', () => { + classUnderTest.attribute.required = true; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-required-icon' + ); + }); + + it('should render an image', () => { + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-attribute-img' + ); + }); + }); + + describe('Get required message key', () => { + it('should return a single-select message key for radio button attribute type', () => { + expect(classUnderTest.getRequiredMessageKey()).toContain( + 'singleSelectRequiredMessage' + ); + }); + + it('should return a single-select message key for ddlb attribute type', () => { + classUnderTest.attribute.uiType = Configurator.UiType.DROPDOWN; + expect(classUnderTest.getRequiredMessageKey()).toContain( + 'singleSelectRequiredMessage' + ); + }); + + it('should return a single-select message key for single-selection-image attribute type', () => { + classUnderTest.attribute.uiType = + Configurator.UiType.SINGLE_SELECTION_IMAGE; + expect(classUnderTest.getRequiredMessageKey()).toContain( + 'singleSelectRequiredMessage' + ); + }); + + it('should return a multi-select message key for check box list attribute type', () => { + classUnderTest.attribute.uiType = Configurator.UiType.CHECKBOXLIST; + expect(classUnderTest.getRequiredMessageKey()).toContain( + 'multiSelectRequiredMessage' + ); + }); + + it('should return a multi-select message key for multi-selection-image list attribute type', () => { + classUnderTest.attribute.uiType = + Configurator.UiType.MULTI_SELECTION_IMAGE; + expect(classUnderTest.getRequiredMessageKey()).toContain( + 'multiSelectRequiredMessage' + ); + }); + + it('should return no key for not implemented attribute type', () => { + classUnderTest.attribute.uiType = Configurator.UiType.NOT_IMPLEMENTED; + expect(classUnderTest.getRequiredMessageKey()).toContain( + 'singleSelectRequiredMessage' + ); + }); + + it('should return no key for read only attribute type', () => { + classUnderTest.attribute.uiType = Configurator.UiType.READ_ONLY; + expect(classUnderTest.getRequiredMessageKey()).toContain( + 'singleSelectRequiredMessage' + ); + }); + }); + + describe('Required message at the attribute level', () => { + it('should render a required message if attribute has been set, yet.', () => { + classUnderTest.attribute.required = true; + classUnderTest.attribute.uiType = Configurator.UiType.RADIOBUTTON; + classUnderTest.ngOnInit(); + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-required-error-msg' + ); + }); + + it('should render a required message if the group has already been visited.', () => { + classUnderTest.owner.type = CommonConfigurator.OwnerType.PRODUCT; + isCartEntryOrGroupVisited = true; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + '.cx-required-error-msg' + ); + }); + + it("shouldn't render a required message if attribute has not been added to the cart yet.", () => { + classUnderTest.owner.type = CommonConfigurator.OwnerType.PRODUCT; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + '.cx-required-error-msg' + ); + }); + + it("shouldn't render a required message if attribute is not required.", () => { + classUnderTest.attribute.required = false; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + '.cx-required-error-msg' + ); + }); + + it("shouldn't render a required message if attribute is complete.", () => { + classUnderTest.attribute.incomplete = true; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + '.cx-required-error-msg' + ); + }); + + it("shouldn't render a required message if ui type is string.", () => { + classUnderTest.attribute.uiType = Configurator.UiType.STRING; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + '.cx-required-error-msg' + ); + }); + }); + + describe('Conflict text at the attribute level', () => { + it('should render conflict icon with corresponding message if attribute has conflicts.', () => { + classUnderTest.attribute.hasConflicts = true; + classUnderTest.groupType = Configurator.GroupType.ATTRIBUTE_GROUP; + fixture.detectChanges(); + + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-conflict-msg' + ); + + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'cx-icon' + ); + }); + + it('should render conflict message without icon container if conflict message is not displayed in the configuration.', () => { + classUnderTest.attribute.hasConflicts = true; + classUnderTest.groupType = Configurator.GroupType.CONFLICT_GROUP; + fixture.detectChanges(); + + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-conflict-msg' + ); + + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + 'cx-icon' + ); + }); + + it("shouldn't render conflict message if attribute has no conflicts.", () => { + classUnderTest.attribute.hasConflicts = false; + fixture.detectChanges(); + + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + '.cx-conflict-container' + ); + }); + }); + + describe('Verify attribute type', () => { + it("should return 'true'", () => { + classUnderTest.groupType = Configurator.GroupType.ATTRIBUTE_GROUP; + fixture.detectChanges(); + expect(classUnderTest.isAttributeGroup(classUnderTest.groupType)).toBe( + true + ); + }); + + it("should return 'false'", () => { + classUnderTest.groupType = Configurator.GroupType.CONFLICT_GROUP; + fixture.detectChanges(); + expect(classUnderTest.isAttributeGroup(classUnderTest.groupType)).toBe( + false + ); + }); + }); + + describe('Get conflict message key', () => { + it("should return 'configurator.conflict.viewConflictDetails' conflict message key", () => { + classUnderTest.groupType = Configurator.GroupType.ATTRIBUTE_GROUP; + fixture.detectChanges(); + expect( + classUnderTest.getConflictMessageKey(classUnderTest.groupType) + ).toEqual('configurator.conflict.viewConflictDetails'); + }); + + it("should return 'configurator.conflict.viewConfigurationDetails' conflict message key", () => { + classUnderTest.groupType = Configurator.GroupType.CONFLICT_GROUP; + fixture.detectChanges(); + expect( + classUnderTest.getConflictMessageKey(classUnderTest.groupType) + ).toEqual('configurator.conflict.viewConfigurationDetails'); + }); + }); +}); 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 new file mode 100644 index 00000000000..9a662e2bd52 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.component.ts @@ -0,0 +1,125 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + OnInit, +} from '@angular/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { ICON_TYPE } from '@spartacus/storefront'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Configurator } from '../../../core/model/configurator.model'; +import { ConfiguratorStorefrontUtilsService } from '../../service/configurator-storefront-utils.service'; +import { ConfiguratorAttributeBaseComponent } from '../types/base/configurator-attribute-base.component'; + +@Component({ + selector: 'cx-configurator-attribute-header', + templateUrl: './configurator-attribute-header.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorAttributeHeaderComponent + extends ConfiguratorAttributeBaseComponent + implements OnInit { + @Input() attribute: Configurator.Attribute; + @Input() owner: CommonConfigurator.Owner; + @Input() groupId: string; + @Input() groupType: Configurator.GroupType; + + iconTypes = ICON_TYPE; + showRequiredMessageForDomainAttribute$: Observable; + + constructor(protected configUtils: ConfiguratorStorefrontUtilsService) { + super(); + } + + ngOnInit(): void { + /** + * Show message that indicates that attribute is required in case attribute has a domain of values + */ + this.showRequiredMessageForDomainAttribute$ = this.configUtils + .isCartEntryOrGroupVisited(this.owner, this.groupId) + .pipe( + map((result) => (result ? this.isRequiredAttributeWithDomain() : false)) + ); + } + + /** + * Get message key for the required message. Is different for multi- and single selection values + * @return {string} - required message key + */ + getRequiredMessageKey(): string { + if (this.isSingleSelection()) { + return 'configurator.attribute.singleSelectRequiredMessage'; + } else if (this.isMultiSelection()) { + return 'configurator.attribute.multiSelectRequiredMessage'; + } else { + //input attribute types + return 'configurator.attribute.singleSelectRequiredMessage'; + } + } + + protected isMultiSelection(): boolean { + switch (this.attribute.uiType) { + case Configurator.UiType.CHECKBOXLIST: + case Configurator.UiType.MULTI_SELECTION_IMAGE: { + return true; + } + } + return false; + } + + protected isSingleSelection(): boolean { + switch (this.attribute.uiType) { + case Configurator.UiType.RADIOBUTTON: + case Configurator.UiType.CHECKBOX: + case Configurator.UiType.DROPDOWN: + case Configurator.UiType.SINGLE_SELECTION_IMAGE: { + return true; + } + } + return false; + } + + protected isRequiredAttributeWithDomain(): boolean { + const uiType = this.attribute.uiType; + return ( + this.attribute.required && + this.attribute.incomplete && + uiType !== Configurator.UiType.NOT_IMPLEMENTED && + uiType !== Configurator.UiType.STRING && + uiType !== Configurator.UiType.NUMERIC + ); + } + + /** + * Verifies whether the group type is attribute group + * + * @param groupType {Configurator.GroupType} - group type + * @return {boolean} - 'true' if the group type is 'attribute group' otherwise 'false' + */ + isAttributeGroup(groupType: Configurator.GroupType): boolean { + if (Configurator.GroupType.ATTRIBUTE_GROUP === groupType) { + return true; + } + return false; + } + + /** + * Retrieves a certain conflict link key depending on the current group type for translation. + * + * @param groupType {Configurator.GroupType}- group type + * @return {string} - the conflict link key + */ + getConflictMessageKey(groupType: Configurator.GroupType): string { + switch (groupType) { + case Configurator.GroupType.CONFLICT_GROUP: { + return 'configurator.conflict.viewConfigurationDetails'; + } + case Configurator.GroupType.ATTRIBUTE_GROUP: { + return 'configurator.conflict.viewConflictDetails'; + } + default: + break; + } + } +} 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 new file mode 100644 index 00000000000..3a1bc63556c --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/header/configurator-attribute-header.module.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { I18nModule } from '@spartacus/core'; +import { IconModule } from '@spartacus/storefront'; +import { ConfiguratorAttributeHeaderComponent } from './configurator-attribute-header.component'; + +@NgModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + CommonModule, + I18nModule, + IconModule, + NgSelectModule, + ], + declarations: [ConfiguratorAttributeHeaderComponent], + exports: [ConfiguratorAttributeHeaderComponent], + entryComponents: [ConfiguratorAttributeHeaderComponent], +}) +export class ConfiguratorAttributeHeaderModule {} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/header/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/header/index.ts new file mode 100644 index 00000000000..b3a5b7bdefd --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/header/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-attribute-header.component'; +export * from './configurator-attribute-header.module'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/index.ts new file mode 100644 index 00000000000..b81f59975c7 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/index.ts @@ -0,0 +1,12 @@ +export * from './footer/index'; +export * from './header/index'; +export * from './types/base/index'; +export * from './types/checkbox-list/index'; +export * from './types/checkbox/index'; +export * from './types/drop-down/index'; +export * from './types/input-field/index'; +export * from './types/multi-selection-image/index'; +export * from './types/numeric-input-field/index'; +export * from './types/radio-button/index'; +export * from './types/read-only/index'; +export * from './types/single-selection-image/index'; 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 new file mode 100644 index 00000000000..7eabf6407c3 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.spec.ts @@ -0,0 +1,120 @@ +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfiguratorAttributeBaseComponent } from './configurator-attribute-base.component'; + +describe('ConfigUIKeyGeneratorService', () => { + let classUnderTest: ConfiguratorAttributeBaseComponent; + + const currentAttribute: Configurator.Attribute = { + name: 'attributeId', + uiType: Configurator.UiType.RADIOBUTTON, + }; + + beforeEach(() => { + classUnderTest = new ConfiguratorAttributeBaseComponent(); + }); + + it('should generate value key', () => { + expect( + classUnderTest.createValueUiKey('prefix', 'attributeId', 'valueId') + ).toBe('cx-configurator--prefix--attributeId--valueId'); + }); + + it('should generate attribute key', () => { + expect(classUnderTest.createAttributeUiKey('prefix', 'attributeId')).toBe( + 'cx-configurator--prefix--attributeId' + ); + }); + + it('should return only attribute id for aria-labelledby', () => { + expect( + classUnderTest.createAriaLabelledBy('prefix', 'attributeId') + ).toEqual('cx-configurator--label--attributeId'); + }); + + it("should return only attribute id for aria-labelledby because value id is 'undefined'", () => { + expect( + classUnderTest.createAriaLabelledBy('prefix', 'attributeId', undefined) + ).toEqual('cx-configurator--label--attributeId'); + }); + + it("should return only attribute id for aria-labelledby because value id is 'null'", () => { + expect( + classUnderTest.createAriaLabelledBy('prefix', 'attributeId', null) + ).toEqual('cx-configurator--label--attributeId'); + }); + + it('should return attribute id, value id and without quantity for aria-labelledby', () => { + expect( + classUnderTest.createAriaLabelledBy('prefix', 'attributeId', 'valueId') + ).toEqual( + 'cx-configurator--label--attributeId cx-configurator--prefix--attributeId--valueId cx-configurator--price--optionsPriceValue--attributeId--valueId' + ); + }); + + it('should return attribute id, value id and with undefined quantity for aria-labelledby', () => { + expect( + classUnderTest.createAriaLabelledBy( + 'prefix', + 'attributeId', + 'valueId', + undefined + ) + ).toEqual( + 'cx-configurator--label--attributeId cx-configurator--prefix--attributeId--valueId cx-configurator--price--optionsPriceValue--attributeId--valueId' + ); + }); + + it("should return attribute id, value id and with quantity equals 'null' for aria-labelledby", () => { + expect( + classUnderTest.createAriaLabelledBy( + 'prefix', + 'attributeId', + 'valueId', + null + ) + ).toEqual( + 'cx-configurator--label--attributeId cx-configurator--prefix--attributeId--valueId cx-configurator--price--optionsPriceValue--attributeId--valueId' + ); + }); + + it("should return attribute id, value id and with quantity equals 'true' for aria-labelledby", () => { + expect( + classUnderTest.createAriaLabelledBy( + 'prefix', + 'attributeId', + 'valueId', + true + ) + ).toEqual( + 'cx-configurator--label--attributeId cx-configurator--prefix--attributeId--valueId cx-configurator--price--optionsPriceValue--attributeId--valueId' + ); + }); + + it("should return attribute id, value id and with quantity equals 'false' for aria-labelledby", () => { + expect( + classUnderTest.createAriaLabelledBy( + 'prefix', + 'attributeId', + 'valueId', + false + ) + ).toEqual( + 'cx-configurator--label--attributeId cx-configurator--prefix--attributeId--valueId cx-configurator--option--price--attributeId--valueId' + ); + }); + + it('should generate attribute id for configurator', () => { + expect( + classUnderTest.createAttributeIdForConfigurator(currentAttribute) + ).toBe('cx-configurator--radioGroup--attributeId'); + }); + + it('should generate value id for configurator', () => { + expect( + classUnderTest.createAttributeValueIdForConfigurator( + currentAttribute, + 'valueId' + ) + ).toBe('cx-configurator--radioGroup--attributeId--valueId'); + }); +}); 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 new file mode 100644 index 00000000000..545782d1b58 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/base/configurator-attribute-base.component.ts @@ -0,0 +1,122 @@ +import { Configurator } from '../../../../core/model/configurator.model'; + +/** + * Service to provide unique keys for elements on the UI and for sending to configurator + */ + +export class ConfiguratorAttributeBaseComponent { + private static SEPERATOR = '--'; + private static PREFIX = 'cx-configurator'; + private static PREFIX_LABEL = 'label'; + private static PREFIX_OPTION_PRICE_VALUE = 'price--optionsPriceValue'; + private static PREFIX_DDLB_OPTION_PRICE_VALUE = 'option--price'; + + /** + * Creates unique key for config value on the UI + * @param prefix for key depending on usage (e.g. uiType, label) + * @param attributeId + * @param valueId + */ + createValueUiKey( + prefix: string, + attributeId: string, + valueId: string + ): string { + return ( + this.createAttributeUiKey(prefix, attributeId) + + ConfiguratorAttributeBaseComponent.SEPERATOR + + valueId + ); + } + + /** + * Creates unique key for config value to be sent to configurator + * @param currentAttribute + * @param value + */ + createAttributeValueIdForConfigurator( + currentAttribute: Configurator.Attribute, + value: string + ): string { + return this.createValueUiKey( + currentAttribute.uiType, + currentAttribute.name, + value + ); + } + + /** + * Creates unique key for config attribute on the UI + * @param prefix for key depending on usage (e.g. uiType, label) + * @param attributeId + */ + createAttributeUiKey(prefix: string, attributeId: string): string { + return ( + ConfiguratorAttributeBaseComponent.PREFIX + + ConfiguratorAttributeBaseComponent.SEPERATOR + + prefix + + ConfiguratorAttributeBaseComponent.SEPERATOR + + attributeId + ); + } + + /** + * Creates unique key for config attribute to be sent to configurator + * @param currentAttribute + */ + createAttributeIdForConfigurator( + currentAttribute: Configurator.Attribute + ): string { + if (currentAttribute) { + return this.createAttributeUiKey( + currentAttribute.uiType, + currentAttribute.name + ); + } + } + + /** + * Creates unique key for attribute 'aria-labelledby' + * @param prefix + * @param attributeId + * @param valueId + * @param hasQuantity + */ + createAriaLabelledBy( + prefix: string, + attributeId: string, + valueId?: string, + hasQuantity?: boolean + ): string { + let attributeUiKey = this.createAttributeUiKey( + ConfiguratorAttributeBaseComponent.PREFIX_LABEL, + attributeId + ); + if (valueId) { + attributeUiKey += + ' ' + + this.createAttributeUiKey(prefix, attributeId) + + ConfiguratorAttributeBaseComponent.SEPERATOR + + valueId + + ' '; + if (typeof hasQuantity === 'boolean' && !hasQuantity) { + attributeUiKey += + this.createAttributeUiKey( + ConfiguratorAttributeBaseComponent.PREFIX_DDLB_OPTION_PRICE_VALUE, + attributeId + ) + + ConfiguratorAttributeBaseComponent.SEPERATOR + + valueId; + } else { + attributeUiKey += + this.createAttributeUiKey( + ConfiguratorAttributeBaseComponent.PREFIX_OPTION_PRICE_VALUE, + attributeId + ) + + ConfiguratorAttributeBaseComponent.SEPERATOR + + valueId; + } + } + return attributeUiKey; + } +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/base/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/base/index.ts new file mode 100644 index 00000000000..81c63d9c474 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/base/index.ts @@ -0,0 +1 @@ +export * from './configurator-attribute-base.component'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.html new file mode 100644 index 00000000000..41f9466a21a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.html @@ -0,0 +1,24 @@ +
+
+ + +
+
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.spec.ts new file mode 100644 index 00000000000..4917e9ef766 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.spec.ts @@ -0,0 +1,109 @@ +import { ChangeDetectionStrategy, Directive, Input } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { ConfiguratorGroupsService } from '../../../../core/facade/configurator-groups.service'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfiguratorStorefrontUtilsService } from '../../../service/configurator-storefront-utils.service'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +import { ConfiguratorAttributeCheckBoxListComponent } from './configurator-attribute-checkbox-list.component'; + +class MockGroupService {} +@Directive({ + selector: '[cxFocus]', +}) +export class MockFocusDirective { + @Input('cxFocus') protected config; +} + +describe('ConfigAttributeCheckBoxListComponent', () => { + let component: ConfiguratorAttributeCheckBoxListComponent; + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + ConfiguratorAttributeCheckBoxListComponent, + MockFocusDirective, + ], + imports: [ReactiveFormsModule, NgSelectModule], + providers: [ + ConfiguratorAttributeBaseComponent, + ConfiguratorStorefrontUtilsService, + { + provide: ConfiguratorGroupsService, + useClass: MockGroupService, + }, + ], + }) + .overrideComponent(ConfiguratorAttributeCheckBoxListComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + function createValue(code: string, name: string, isSelected: boolean) { + const value: Configurator.Value = { + valueCode: code, + name: name, + selected: isSelected, + }; + return value; + } + + beforeEach(() => { + const value1 = createValue('1', 'val1', true); + const value2 = createValue('2', 'val2', false); + const value3 = createValue('3', 'val3', true); + const values: Configurator.Value[] = [value1, value2, value3]; + + fixture = TestBed.createComponent( + ConfiguratorAttributeCheckBoxListComponent + ); + + component = fixture.componentInstance; + + component.attribute = { + name: 'attributeName', + attrCode: 444, + uiType: Configurator.UiType.CHECKBOXLIST, + values: values, + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have 3 entries after init with first and last value filled', () => { + expect(component.attributeCheckBoxForms.length).toBe(3); + expect(component.attributeCheckBoxForms[0].value).toBe(true); + expect(component.attributeCheckBoxForms[1].value).toBe(false); + expect(component.attributeCheckBoxForms[2].value).toBe(true); + }); + + it('should select and deselect a checkbox value', () => { + const checkboxId = + '#cx-configurator--checkBoxList--' + + component.attribute.name + + '--' + + component.attribute.values[1].valueCode; + const valueToSelect = fixture.debugElement.query(By.css(checkboxId)) + .nativeElement; + expect(valueToSelect.checked).toBeFalsy(); + // select value + valueToSelect.click(); + fixture.detectChanges(); + expect(valueToSelect.checked).toBeTruthy(); + // deselect value + valueToSelect.click(); + fixture.detectChanges(); + expect(valueToSelect.checked).toBeFalsy(); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.ts new file mode 100644 index 00000000000..d28032f3333 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.component.ts @@ -0,0 +1,69 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfigFormUpdateEvent } from '../../../form/configurator-form.event'; +import { ConfiguratorStorefrontUtilsService } from '../../../service/configurator-storefront-utils.service'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +@Component({ + selector: 'cx-configurator-attribute-checkbox-list', + templateUrl: './configurator-attribute-checkbox-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorAttributeCheckBoxListComponent + extends ConfiguratorAttributeBaseComponent + implements OnInit { + @Input() attribute: Configurator.Attribute; + @Input() group: string; + @Input() ownerKey: string; + + @Output() selectionChange = new EventEmitter(); + + constructor( + protected configUtilsService: ConfiguratorStorefrontUtilsService + ) { + super(); + } + + attributeCheckBoxForms = new Array(); + + ngOnInit() { + for (const value of this.attribute.values) { + let attributeCheckBoxForm; + if (value.selected === true) { + attributeCheckBoxForm = new FormControl(true); + } else { + attributeCheckBoxForm = new FormControl(false); + } + this.attributeCheckBoxForms.push(attributeCheckBoxForm); + } + } + + /** + * Triggered when a value is selected + */ + onSelect(): void { + const selectedValues = this.configUtilsService.assembleValuesForMultiSelectAttributes( + this.attributeCheckBoxForms, + this.attribute + ); + + const event: ConfigFormUpdateEvent = { + ownerKey: this.ownerKey, + changedAttribute: { + name: this.attribute.name, + values: selectedValues, + uiType: this.attribute.uiType, + groupId: this.attribute.groupId, + }, + }; + + this.selectionChange.emit(event); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.module.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.module.ts new file mode 100644 index 00000000000..119e4c538ea --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/configurator-attribute-checkbox-list.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { I18nModule } from '@spartacus/core'; +import { KeyboardFocusModule } from '@spartacus/storefront'; +import { ConfiguratorAttributeCheckBoxListComponent } from './configurator-attribute-checkbox-list.component'; + +@NgModule({ + imports: [ + KeyboardFocusModule, + FormsModule, + ReactiveFormsModule, + CommonModule, + I18nModule, + ], + declarations: [ConfiguratorAttributeCheckBoxListComponent], + exports: [ConfiguratorAttributeCheckBoxListComponent], + entryComponents: [ConfiguratorAttributeCheckBoxListComponent], +}) +export class ConfiguratorAttributeCheckboxListModule {} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/index.ts new file mode 100644 index 00000000000..c1b2211dd98 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox-list/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-attribute-checkbox-list.component'; +export * from './configurator-attribute-checkbox-list.module'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.component.html new file mode 100644 index 00000000000..e944245f8f8 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.component.html @@ -0,0 +1,32 @@ +
+
+ + +
+
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.component.spec.ts new file mode 100644 index 00000000000..ae4c9672aac --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.component.spec.ts @@ -0,0 +1,90 @@ +import { ChangeDetectionStrategy, Directive, Input } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +import { ConfiguratorAttributeCheckBoxComponent } from './configurator-attribute-checkbox.component'; + +@Directive({ + selector: '[cxFocus]', +}) +export class MockFocusDirective { + @Input('cxFocus') protected config; +} +describe('ConfigAttributeCheckBoxComponent', () => { + let component: ConfiguratorAttributeCheckBoxComponent; + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + ConfiguratorAttributeCheckBoxComponent, + MockFocusDirective, + ], + imports: [ReactiveFormsModule, NgSelectModule], + providers: [ConfiguratorAttributeBaseComponent], + }) + .overrideComponent(ConfiguratorAttributeCheckBoxComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + function createValue(code: string, name: string, isSelected: boolean) { + const value: Configurator.Value = { + valueCode: code, + name: name, + selected: isSelected, + }; + return value; + } + + beforeEach(() => { + const value1 = createValue('1', 'val1', false); + const values: Configurator.Value[] = [value1]; + + fixture = TestBed.createComponent(ConfiguratorAttributeCheckBoxComponent); + component = fixture.componentInstance; + + component.attribute = { + name: 'attributeName', + attrCode: 444, + uiType: Configurator.UiType.CHECKBOX, + values: values, + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have an entry after init with empty value', () => { + expect(component.attributeCheckBoxForm.value).toBeFalsy(); + }); + + it('should select and deselect a checkbox value', () => { + const checkboxId = + '#cx-configurator--checkBox--' + + component.attribute.name + + '--' + + component.attribute.values[0].valueCode; + const valueToSelect = fixture.debugElement.query(By.css(checkboxId)) + .nativeElement; + expect(valueToSelect.checked).toBeFalsy(); + // select value + valueToSelect.click(); + fixture.detectChanges(); + expect(valueToSelect.checked).toBeTruthy(); + // deselect value + valueToSelect.click(); + fixture.detectChanges(); + expect(valueToSelect.checked).toBeFalsy(); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.component.ts new file mode 100644 index 00000000000..d1ac85f6b3c --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.component.ts @@ -0,0 +1,61 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfigFormUpdateEvent } from '../../../form/configurator-form.event'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; + +@Component({ + selector: 'cx-configurator-attribute-checkbox', + templateUrl: './configurator-attribute-checkbox.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorAttributeCheckBoxComponent + extends ConfiguratorAttributeBaseComponent + implements OnInit { + @Input() attribute: Configurator.Attribute; + @Input() group: string; + @Input() ownerKey: string; + @Output() selectionChange = new EventEmitter(); + + attributeCheckBoxForm = new FormControl(''); + + ngOnInit() { + this.attributeCheckBoxForm.setValue(this.attribute.selectedSingleValue); + } + + /** + * Fired when a check box has been selected i.e. when a value has been set + */ + onSelect(): void { + const selectedValues = this.assembleSingleValue(); + + const event: ConfigFormUpdateEvent = { + ownerKey: this.ownerKey, + changedAttribute: { + name: this.attribute.name, + values: selectedValues, + uiType: this.attribute.uiType, + groupId: this.attribute.groupId, + }, + }; + this.selectionChange.emit(event); + } + + protected assembleSingleValue(): Configurator.Value[] { + const localAssembledValues: Configurator.Value[] = []; + + const localAttributeValue: Configurator.Value = {}; + localAttributeValue.valueCode = this.attribute.values[0].valueCode; + localAttributeValue.name = this.attribute.values[0].name; + localAttributeValue.selected = this.attributeCheckBoxForm.value; + localAssembledValues.push(localAttributeValue); + return localAssembledValues; + } +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.module.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.module.ts new file mode 100644 index 00000000000..ae5afd62fa6 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/configurator-attribute-checkbox.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { I18nModule } from '@spartacus/core'; +import { KeyboardFocusModule } from '@spartacus/storefront'; +import { ConfiguratorAttributeCheckBoxComponent } from './configurator-attribute-checkbox.component'; + +@NgModule({ + imports: [ + KeyboardFocusModule, + FormsModule, + ReactiveFormsModule, + CommonModule, + I18nModule, + ], + declarations: [ConfiguratorAttributeCheckBoxComponent], + exports: [ConfiguratorAttributeCheckBoxComponent], + entryComponents: [ConfiguratorAttributeCheckBoxComponent], +}) +export class ConfiguratorAttributeCheckboxModule {} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/index.ts new file mode 100644 index 00000000000..1e6868f2d69 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/checkbox/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-attribute-checkbox.component'; +export * from './configurator-attribute-checkbox.module'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.component.html new file mode 100644 index 00000000000..7fb33adb949 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.component.html @@ -0,0 +1,21 @@ +
+ +
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.component.spec.ts new file mode 100644 index 00000000000..faf9d8e72e4 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.component.spec.ts @@ -0,0 +1,71 @@ +import { ChangeDetectionStrategy } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +import { ConfiguratorAttributeDropDownComponent } from './configurator-attribute-drop-down.component'; + +describe('ConfigAttributeDropDownComponent', () => { + let component: ConfiguratorAttributeDropDownComponent; + let fixture: ComponentFixture; + const ownerKey = 'theOwnerKey'; + const name = 'theName'; + const groupId = 'theGroupId'; + const selectedValue = 'selectedValue'; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ConfiguratorAttributeDropDownComponent], + imports: [ReactiveFormsModule, NgSelectModule], + providers: [ConfiguratorAttributeBaseComponent], + }) + .overrideComponent(ConfiguratorAttributeDropDownComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorAttributeDropDownComponent); + component = fixture.componentInstance; + component.attribute = { + name: name, + attrCode: 444, + uiType: Configurator.UiType.DROPDOWN, + selectedSingleValue: selectedValue, + quantity: 1, + groupId: groupId, + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set selectedSingleValue on init', () => { + expect(component.attributeDropDownForm.value).toEqual(selectedValue); + }); + + it('should call emit of selectionChange onSelect', () => { + component.ownerKey = ownerKey; + spyOn(component.selectionChange, 'emit').and.callThrough(); + component.onSelect(); + expect(component.selectionChange.emit).toHaveBeenCalledWith( + jasmine.objectContaining({ + ownerKey: ownerKey, + changedAttribute: jasmine.objectContaining({ + name: name, + uiType: Configurator.UiType.DROPDOWN, + groupId: groupId, + selectedSingleValue: component.attributeDropDownForm.value, + }), + }) + ); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.component.ts new file mode 100644 index 00000000000..bf7b3ae6052 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.component.ts @@ -0,0 +1,46 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfigFormUpdateEvent } from '../../../form/configurator-form.event'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +@Component({ + selector: 'cx-configurator-attribute-drop-down', + templateUrl: './configurator-attribute-drop-down.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorAttributeDropDownComponent + extends ConfiguratorAttributeBaseComponent + implements OnInit { + attributeDropDownForm = new FormControl(''); + @Input() attribute: Configurator.Attribute; + @Input() group: string; + @Input() ownerKey: string; + + @Output() selectionChange = new EventEmitter(); + + ngOnInit() { + this.attributeDropDownForm.setValue(this.attribute.selectedSingleValue); + } + /** + * Triggered when a value has been selected + */ + onSelect(): void { + const event: ConfigFormUpdateEvent = { + ownerKey: this.ownerKey, + changedAttribute: { + name: this.attribute.name, + selectedSingleValue: this.attributeDropDownForm.value, + uiType: this.attribute.uiType, + groupId: this.attribute.groupId, + }, + }; + this.selectionChange.emit(event); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.module.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.module.ts new file mode 100644 index 00000000000..1423ad8d777 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/configurator-attribute-drop-down.module.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { I18nModule } from '@spartacus/core'; +import { KeyboardFocusModule } from '@spartacus/storefront'; +import { ConfiguratorAttributeDropDownComponent } from './configurator-attribute-drop-down.component'; + +@NgModule({ + imports: [ + KeyboardFocusModule, + FormsModule, + ReactiveFormsModule, + CommonModule, + I18nModule, + NgSelectModule, + ], + declarations: [ConfiguratorAttributeDropDownComponent], + exports: [ConfiguratorAttributeDropDownComponent], + entryComponents: [ConfiguratorAttributeDropDownComponent], +}) +export class ConfiguratorAttributeDropDownModule {} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/index.ts new file mode 100644 index 00000000000..e39ba8b8da3 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/drop-down/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-attribute-drop-down.component'; +export * from './configurator-attribute-drop-down.module'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.component.html new file mode 100644 index 00000000000..5381a37fecf --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.component.html @@ -0,0 +1,12 @@ +
+ +
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.component.spec.ts new file mode 100644 index 00000000000..c84ad22ef58 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.component.spec.ts @@ -0,0 +1,109 @@ +import { ChangeDetectionStrategy, Directive, Input } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +import { ConfiguratorAttributeInputFieldComponent } from './configurator-attribute-input-field.component'; + +@Directive({ + selector: '[cxFocus]', +}) +export class MockFocusDirective { + @Input('cxFocus') protected config; +} + +describe('ConfigAttributeInputFieldComponent', () => { + let component: ConfiguratorAttributeInputFieldComponent; + let fixture: ComponentFixture; + const ownerKey = 'theOwnerKey'; + const name = 'theName'; + const groupId = 'theGroupId'; + const userInput = 'theUserInput'; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + ConfiguratorAttributeInputFieldComponent, + MockFocusDirective, + ], + imports: [ReactiveFormsModule], + providers: [ConfiguratorAttributeBaseComponent], + }) + .overrideComponent(ConfiguratorAttributeInputFieldComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorAttributeInputFieldComponent); + component = fixture.componentInstance; + component.attribute = { + name: name, + uiType: Configurator.UiType.STRING, + userInput: undefined, + required: true, + incomplete: true, + groupId: groupId, + }; + component.ownerType = CommonConfigurator.OwnerType.CART_ENTRY; + component.ownerKey = ownerKey; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not add classes ng-touch and ng-invalid to the input field.', () => { + component.attribute.required = false; + fixture.detectChanges(); + const styleClasses = fixture.debugElement.query( + By.css('input.form-control') + ).nativeElement.classList; + expect(styleClasses).toContain('ng-touched'); + expect(styleClasses).not.toContain('ng-invalid'); + }); + + it('should add classes ng-touch and ng-invalid to the input field.', () => { + const styleClasses = fixture.debugElement.query( + By.css('input.form-control') + ).nativeElement.classList; + expect(styleClasses).toContain('ng-touched'); + expect(styleClasses).toContain('ng-invalid'); + }); + + it('should set form as touched on init', () => { + expect(component.attributeInputForm.touched).toEqual(true); + }); + + it('should call emit of selectionChange onSelect', () => { + component.attributeInputForm.setValue(userInput); + spyOn(component.inputChange, 'emit').and.callThrough(); + component.onChange(); + expect(component.inputChange.emit).toHaveBeenCalledWith( + jasmine.objectContaining({ + ownerKey: ownerKey, + changedAttribute: jasmine.objectContaining({ + name: name, + uiType: Configurator.UiType.STRING, + groupId: groupId, + userInput: userInput, + }), + }) + ); + }); + + it('should set userInput on init', () => { + component.attribute.userInput = userInput; + fixture.detectChanges(); + component.ngOnInit(); + expect(component.attributeInputForm.value).toEqual(userInput); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.component.ts new file mode 100644 index 00000000000..f528d1aa189 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.component.ts @@ -0,0 +1,59 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfigFormUpdateEvent } from '../../../form/configurator-form.event'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; + +@Component({ + selector: 'cx-configurator-attribute-input-field', + templateUrl: './configurator-attribute-input-field.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorAttributeInputFieldComponent + extends ConfiguratorAttributeBaseComponent + implements OnInit { + attributeInputForm = new FormControl(''); + @Input() ownerType: CommonConfigurator.OwnerType; + @Input() attribute: Configurator.Attribute; + @Input() group: string; + @Input() ownerKey: string; + + @Output() inputChange = new EventEmitter(); + + ngOnInit() { + this.attributeInputForm.setValue(this.attribute.userInput); + if ( + this.ownerType === CommonConfigurator.OwnerType.CART_ENTRY && + this.attribute.required && + this.attribute.incomplete && + !this.attributeInputForm.value + ) { + this.attributeInputForm.markAsTouched(); + } + } + + /** + * Triggered when the user input has been changed + */ + onChange(): void { + const event: ConfigFormUpdateEvent = { + ownerKey: this.ownerKey, + changedAttribute: { + name: this.attribute.name, + userInput: this.attributeInputForm.value, + uiType: this.attribute.uiType, + groupId: this.attribute.groupId, + }, + }; + + this.inputChange.emit(event); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.module.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.module.ts new file mode 100644 index 00000000000..08a775ae768 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/configurator-attribute-input-field.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { I18nModule } from '@spartacus/core'; +import { KeyboardFocusModule } from '@spartacus/storefront'; +import { ConfiguratorAttributeInputFieldComponent } from './configurator-attribute-input-field.component'; + +@NgModule({ + imports: [ + KeyboardFocusModule, + FormsModule, + ReactiveFormsModule, + CommonModule, + I18nModule, + ], + declarations: [ConfiguratorAttributeInputFieldComponent], + exports: [ConfiguratorAttributeInputFieldComponent], + entryComponents: [ConfiguratorAttributeInputFieldComponent], +}) +export class ConfiguratorAttributeInputFieldModule {} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/index.ts new file mode 100644 index 00000000000..adf45d83636 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/input-field/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-attribute-input-field.component'; +export * from './configurator-attribute-input-field.module'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.html new file mode 100644 index 00000000000..ffad257b8df --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.html @@ -0,0 +1,44 @@ +
+
+ +
+ +
+
+
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.spec.ts new file mode 100644 index 00000000000..2a44556bf75 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.spec.ts @@ -0,0 +1,139 @@ +import { ChangeDetectionStrategy, Directive, Input } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { ConfiguratorGroupsService } from '../../../../core/facade/configurator-groups.service'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfiguratorStorefrontUtilsService } from '../../../service/configurator-storefront-utils.service'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +import { ConfiguratorAttributeMultiSelectionImageComponent } from './configurator-attribute-multi-selection-image.component'; + +class MockGroupService {} + +@Directive({ + selector: '[cxFocus]', +}) +export class MockFocusDirective { + @Input('cxFocus') protected config; +} + +describe('ConfigAttributeMultiSelectionImageComponent', () => { + let component: ConfiguratorAttributeMultiSelectionImageComponent; + let fixture: ComponentFixture; + let htmlElem: HTMLElement; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + ConfiguratorAttributeMultiSelectionImageComponent, + MockFocusDirective, + ], + imports: [ReactiveFormsModule, NgSelectModule], + providers: [ + ConfiguratorAttributeBaseComponent, + ConfiguratorStorefrontUtilsService, + { + provide: ConfiguratorGroupsService, + useClass: MockGroupService, + }, + ], + }) + .overrideComponent(ConfiguratorAttributeMultiSelectionImageComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + function createImage(url: string, altText: string): Configurator.Image { + const image: Configurator.Image = { + url: url, + altText: altText, + }; + return image; + } + + function createValue( + code: string, + name: string, + isSelected: boolean, + images: Configurator.Image[] + ): Configurator.Value { + const value: Configurator.Value = { + valueCode: code, + name: name, + selected: isSelected, + images: images, + }; + return value; + } + + beforeEach(() => { + const image = createImage('url', 'altText'); + const images: Configurator.Image[] = [image, image, image]; + const value1 = createValue('1', 'val1', false, images); + const value2 = createValue('2', 'val2', true, images); + const value3 = createValue('3', 'val3', true, images); + const value4 = createValue('4', 'val4', false, images); + const values: Configurator.Value[] = [value1, value2, value3, value4]; + + fixture = TestBed.createComponent( + ConfiguratorAttributeMultiSelectionImageComponent + ); + component = fixture.componentInstance; + htmlElem = fixture.nativeElement; + + component.attribute = { + name: 'attributeName', + attrCode: 444, + uiType: Configurator.UiType.MULTI_SELECTION_IMAGE, + required: false, + groupId: 'testGroup', + values: values, + }; + fixture.detectChanges(); + }); + + it('should create a component', () => { + expect(component).toBeTruthy(); + }); + + it('should render 4 multi selection images after init', () => { + component.ngOnInit(); + fixture.detectChanges(); + + expect(htmlElem.querySelectorAll('.cx-img').length).toBe(4); + }); + + it('should mark two values as selected', () => { + expect(component.attributeCheckBoxForms[0].value).toEqual(false); + expect(component.attributeCheckBoxForms[1].value).toEqual(true); + expect(component.attributeCheckBoxForms[2].value).toEqual(true); + expect(component.attributeCheckBoxForms[3].value).toEqual(false); + }); + + it('should select a new value and deselect it again', () => { + const singleSelectionImageId = + '#cx-configurator--multi_selection_image--' + + component.attribute.name + + '--' + + component.attribute.values[0].valueCode + + '-input'; + const valueToSelect = fixture.debugElement.query( + By.css(singleSelectionImageId) + ).nativeElement; + expect(valueToSelect.checked).toBe(false); + valueToSelect.click(); + fixture.detectChanges(); + expect(valueToSelect.checked).toBe(true); + expect(component.attributeCheckBoxForms[0].value).toEqual(true); + valueToSelect.click(); + fixture.detectChanges(); + expect(valueToSelect.checked).toBe(false); + expect(component.attributeCheckBoxForms[0].value).toEqual(false); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.ts new file mode 100644 index 00000000000..30025feb98e --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.ts @@ -0,0 +1,73 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfigFormUpdateEvent } from '../../../form/configurator-form.event'; +import { ConfiguratorStorefrontUtilsService } from '../../../service/configurator-storefront-utils.service'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +@Component({ + selector: 'cx-configurator-attribute-multi-selection-image', + templateUrl: './configurator-attribute-multi-selection-image.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorAttributeMultiSelectionImageComponent + extends ConfiguratorAttributeBaseComponent + implements OnInit { + @Input() attribute: Configurator.Attribute; + @Input() ownerKey: string; + + @Output() selectionChange = new EventEmitter(); + + constructor( + protected configUtilsService: ConfiguratorStorefrontUtilsService + ) { + super(); + } + + attributeCheckBoxForms = new Array(); + + ngOnInit() { + for (const value of this.attribute.values) { + let attributeCheckBoxForm: FormControl; + if (value.selected === true) { + attributeCheckBoxForm = new FormControl(true); + } else { + attributeCheckBoxForm = new FormControl(false); + } + this.attributeCheckBoxForms.push(attributeCheckBoxForm); + } + } + + /** + * Fired when a value has been selected + * @param index Index of selected value + */ + onSelect(index: number): void { + this.attributeCheckBoxForms[index].setValue( + !this.attributeCheckBoxForms[index].value + ); + + const selectedValues = this.configUtilsService.assembleValuesForMultiSelectAttributes( + this.attributeCheckBoxForms, + this.attribute + ); + + const event: ConfigFormUpdateEvent = { + ownerKey: this.ownerKey, + changedAttribute: { + name: this.attribute.name, + values: selectedValues, + uiType: this.attribute.uiType, + groupId: this.attribute.groupId, + }, + }; + + this.selectionChange.emit(event); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.module.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.module.ts new file mode 100644 index 00000000000..d624b0f735a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { I18nModule } from '@spartacus/core'; +import { KeyboardFocusModule } from '@spartacus/storefront'; +import { ConfiguratorAttributeMultiSelectionImageComponent } from './configurator-attribute-multi-selection-image.component'; + +@NgModule({ + imports: [ + KeyboardFocusModule, + FormsModule, + ReactiveFormsModule, + CommonModule, + I18nModule, + ], + declarations: [ConfiguratorAttributeMultiSelectionImageComponent], + exports: [ConfiguratorAttributeMultiSelectionImageComponent], + entryComponents: [ConfiguratorAttributeMultiSelectionImageComponent], +}) +export class ConfiguratorAttributeMultiSelectionImageModule {} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/index.ts new file mode 100644 index 00000000000..3c2b9530a9e --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-attribute-multi-selection-image.component'; +export * from './configurator-attribute-multi-selection-image.module'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.html new file mode 100644 index 00000000000..f6ccac08fcf --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.html @@ -0,0 +1,18 @@ +
+ +
+ {{ + 'configurator.attribute.wrongNumericFormat' + | cxTranslate: { pattern: numericFormatPattern } + }} +
+
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.service.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.service.spec.ts new file mode 100644 index 00000000000..a783d520c33 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.service.spec.ts @@ -0,0 +1,131 @@ +import { TestBed } from '@angular/core/testing'; +import { ConfiguratorAttributeNumericInputFieldService } from './configurator-attribute-numeric-input-field.component.service'; + +describe('ConfigAttributeNumericInputFieldService', () => { + let serviceUnderTest: ConfiguratorAttributeNumericInputFieldService; + + beforeEach(() => { + serviceUnderTest = TestBed.inject( + ConfiguratorAttributeNumericInputFieldService + ); + }); + + it('should be created', () => { + expect(serviceUnderTest).toBeTruthy(); + }); + + it('should accept integer that exactly matches the maximum length ', () => { + expect( + serviceUnderTest.performValidationAccordingToMetaData( + '1,234', + ',', + '.', + 4, + 0 + ) + ).toBe(false); + }); + + it('should accept multiple grouping separators', () => { + expect( + serviceUnderTest.performValidationAccordingToMetaData( + '1,23,4', + ',', + '.', + 4, + 0 + ) + ).toBe(false); + }); + + it('should not accept multiple decimal separators', () => { + expect( + serviceUnderTest.performValidationAccordingToMetaData( + '1234.22.22', + ',', + '.', + 9, + 4 + ) + ).toBe(true); + }); + + it('should not accept input where natural part exceeds its share of total part for a natural number', () => { + expect( + serviceUnderTest.performValidationAccordingToMetaData( + '123434', + ',', + '.', + 9, + 4 + ) + ).toBe(true); + }); + + it('should not accept input where natural part exceeds its share of total part', () => { + expect( + serviceUnderTest.performValidationAccordingToMetaData( + '123434.2', + ',', + '.', + 9, + 4 + ) + ).toBe(true); + }); + + it('should not accept multiple decimal separators in case grouping separator needs escaping', () => { + expect( + serviceUnderTest.performValidationAccordingToMetaData( + '1234,22,22', + '.', + ',', + 9, + 4 + ) + ).toBe(true); + }); + + it('should not accept integer that exceeds the maximum length ', () => { + expect( + serviceUnderTest.performValidationAccordingToMetaData( + '1,234', + ',', + '.', + 3, + 0 + ) + ).toBe(true); + }); + + it('should not accept if numeric input is malformed according to swiss locale settings', () => { + const input = '1,234'; + expect( + serviceUnderTest.performValidationAccordingToMetaData( + input, + "'", + '.', + 4, + 0 + ) + ).toBe(true); + }); + + it('should compile pattern for validation message', () => { + expect( + serviceUnderTest.getPatternForValidationMessage(3, 10, false, 'en') + ).toBe('#,###,###.###'); + }); + + it('should consider negative sign for validation message', () => { + expect( + serviceUnderTest.getPatternForValidationMessage(3, 10, true, 'en') + ).toBe('-#,###,###.###'); + }); + + it('should compile pattern for validation message in case no decimal places are present', () => { + expect( + serviceUnderTest.getPatternForValidationMessage(0, 10, false, 'en') + ).toBe('#,###,###,###'); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.service.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.service.ts new file mode 100644 index 00000000000..58c1df00a43 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.service.ts @@ -0,0 +1,150 @@ +import { + formatNumber, + getLocaleNumberSymbol, + NumberSymbol, +} from '@angular/common'; +import { Injectable } from '@angular/core'; +import { AbstractControl, ValidatorFn } from '@angular/forms'; + +/** + * Provides validation and formatting of numeric input + */ +@Injectable({ providedIn: 'root' }) +export class ConfiguratorAttributeNumericInputFieldService { + /** + * Validates numeric input according to settings that are not derived from the locale but from the attribute + * meta data like the total number of digits and the maximum number of decimal places. + * + * @param input Numeric user input, formatted according to session locale + * @param groupingSeparator Separator for grouping, e.g. ',' for 'en' locale. We allow the grouping separator but + * do not check exactly on the position of it in the numerical input. This e.g. is ok: '12,12,12', will be converted + * to '121,212' after the next roundtrip + * @param decimalSeparator Decimal separator, e.g. '.' for 'en' locale. Must not occur more that 1 time in the input. + * @param numberTotalPlaces Total number of places e.g. 10 + * @param numberDecimalPlaces Number of decimal places e.g. 2 + * @returns {boolean} Did we see a validation error? + */ + performValidationAccordingToMetaData( + input: string, + groupingSeparator: string, + decimalSeparator: string, + numberTotalPlaces: number, + numberDecimalPlaces: number + ): boolean { + const escape = '\\'; + const search: RegExp = new RegExp(escape + groupingSeparator, 'g'); + const woGrouping = input.replace(search, ''); + const splitParts = woGrouping.split(decimalSeparator); + + if (splitParts.length > 2) { + return true; + } + if (splitParts.length === 1) { + return woGrouping.length > numberTotalPlaces - numberDecimalPlaces; + } + + return ( + splitParts[0].length > numberTotalPlaces - numberDecimalPlaces || + splitParts[1].length > numberDecimalPlaces + ); + } + /** + * Get pattern for the message that is displayed when the validation fails. This message e.g. looks like + * 'Wrong format, this numerical attribute should be entered according to pattern ##,###,###.##' + * for the 'en' locale for an attribute with total length of 10 and 2 decimal places. + * + * @param decimalPlaces Number of decimal places + * @param totalLength Total number of digits + * @param negativeAllowed Do we allow negative input? + * @param locale Locale + * @returns {string} The pattern that we display in the validation message + */ + getPatternForValidationMessage( + decimalPlaces: number, + totalLength: number, + negativeAllowed: boolean, + locale: string + ): string { + let input: string = (10 ** totalLength - 1).toString(); + if (decimalPlaces > 0) { + input = + input.substring(0, totalLength - decimalPlaces) + + '.' + + input.substring(totalLength - decimalPlaces, totalLength); + } + const inputAsNumber: number = Number(input); + let formatted = formatNumber( + inputAsNumber, + locale, + '1.' + decimalPlaces + '-' + decimalPlaces + ).replace(/9/g, '#'); + if (negativeAllowed) { + formatted = '-' + formatted; + } + return formatted; + } + + /** + * Returns the validator for the input component that represents numeric input. + * The validator only allows the grouping separator, the decimal separator, an optional '-' sign, + * and the digits between 0..9. This validator does not support the scientific notation of + * attributes. + * + * @param locale The locale + * @param numberDecimalPlaces Number of decimal places + * @param numberTotalPlaces Total number of digits + * @param negativeAllowed: Do we allow negative input? + * @returns {ValidatorFn} The validator + */ + + getNumberFormatValidator( + locale: string, + numberDecimalPlaces: number, + numberTotalPlaces: number, + negativeAllowed: boolean + ): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + const input: string = control.value; + if (input) { + //allowed: only numbers and separators + + const groupingSeparator = getLocaleNumberSymbol( + locale, + NumberSymbol.Group + ); + const decimalSeparator = getLocaleNumberSymbol( + locale, + NumberSymbol.Decimal + ); + const expressionPrefix = negativeAllowed ? '^-?' : '^'; + const expressionOnlyNumericalInput: RegExp = new RegExp( + expressionPrefix + + '[0123456789' + + groupingSeparator + + decimalSeparator + + ']*$' + ); + + if (!expressionOnlyNumericalInput.test(input)) { + return this.createValidationError(true); + } + return this.createValidationError( + this.performValidationAccordingToMetaData( + input, + groupingSeparator, + decimalSeparator, + numberTotalPlaces + (input.includes('-') ? 1 : 0), + numberDecimalPlaces + ) + ); + } + return null; + }; + } + + protected createValidationError( + isError: boolean + ): { [key: string]: any } | null { + return isError ? { wrongFormat: {} } : null; + } +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.spec.ts new file mode 100644 index 00000000000..f9569c9a9ea --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.spec.ts @@ -0,0 +1,178 @@ +import { + ChangeDetectionStrategy, + Directive, + Input, + Pipe, + PipeTransform, +} from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { LanguageService } from '@spartacus/core'; +import { of } from 'rxjs'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +import { ConfiguratorAttributeNumericInputFieldComponent } from './configurator-attribute-numeric-input-field.component'; + +@Pipe({ + name: 'cxTranslate', +}) +class MockTranslateUrlPipe implements PipeTransform { + transform(): any {} +} + +@Directive({ + selector: '[cxFocus]', +}) +export class MockFocusDirective { + @Input('cxFocus') protected config; +} + +function checkForValidationMessage( + component: ConfiguratorAttributeNumericInputFieldComponent, + fixture: ComponentFixture, + htmlElem: HTMLElement, + expectedMessages +) { + component.attributeInputForm.markAsDirty(); + + fixture.detectChanges(); + const validationDiv = htmlElem.getElementsByClassName('cx-validation-msg'); + expect(validationDiv).toBeDefined(); + expect(validationDiv.length).toBe(expectedMessages); +} + +describe('ConfigAttributeNumericInputFieldComponent', () => { + let component: ConfiguratorAttributeNumericInputFieldComponent; + const userInput = '345.00'; + let fixture: ComponentFixture; + let mockLanguageService; + let locale = 'en'; + let htmlElem: HTMLElement; + + beforeEach( + waitForAsync(() => { + mockLanguageService = { + getAll: () => of([]), + getActive: jasmine.createSpy().and.returnValue(of(locale)), + setActive: jasmine.createSpy(), + }; + TestBed.configureTestingModule({ + declarations: [ + ConfiguratorAttributeNumericInputFieldComponent, + MockTranslateUrlPipe, + MockFocusDirective, + ], + imports: [ReactiveFormsModule], + providers: [ + ConfiguratorAttributeBaseComponent, + { provide: LanguageService, useValue: mockLanguageService }, + ], + }) + .overrideComponent(ConfiguratorAttributeNumericInputFieldComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent( + ConfiguratorAttributeNumericInputFieldComponent + ); + component = fixture.componentInstance; + component.attribute = { + name: 'attributeName', + uiType: Configurator.UiType.STRING, + userInput: userInput, + numDecimalPlaces: 2, + numTotalLength: 10, + negativeAllowed: false, + }; + fixture.detectChanges(); + htmlElem = fixture.nativeElement; + }); + + function checkForValidity( + input: string, + negativeAllowed: boolean, + isValid: boolean + ) { + component.attribute.negativeAllowed = negativeAllowed; + component.ngOnInit(); + component.attributeInputForm.setValue(input); + checkForValidationMessage(component, fixture, htmlElem, isValid ? 0 : 1); + } + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set value on init', () => { + component.ngOnInit(); + expect(component.attributeInputForm.value).toEqual(userInput); + }); + + it('should display no validation issue if input is fine, an unknown locale was requested, and we fall back to en locale', () => { + locale = 'Unkonwn'; + component.ngOnInit(); + checkForValidationMessage(component, fixture, htmlElem, 0); + }); + + it('should display a validation issue if alphanumeric characters occur', () => { + checkForValidity('122A23', false, false); + }); + + it('should display a validation issue if negative sign is included but not allowed to', () => { + checkForValidity('-122323', false, false); + }); + + it('should display no validation issue if negative sign is included and allowed', () => { + checkForValidity('-122323', true, true); + }); + + it('should display a validation issue if input is too long', () => { + checkForValidity('123456789.34', false, false); + }); + + it('should display a validation issue if input is too long and negatives allowed', () => { + checkForValidity('123456789.34', true, false); + }); + + it('should display no validation issue if input length matches meta data exactly', () => { + checkForValidity('12345678.34', false, true); + }); + + it('should display no validation issue if input length matches meta data exactly and negatives are allowed', () => { + checkForValidity('12345678.34', true, true); + }); + + it('should display no validation issue for negative value if input length matches meta data exactly and negatives are allowed', () => { + checkForValidity('-12345678.34', true, true); + }); + + it('should display no validation issue for single minus if negatives are allowed', () => { + checkForValidity('-', true, true); + }); + + it('should not set control value in case the model attribute does not carry a value', () => { + component.attribute.userInput = null; + component.ngOnInit(); + expect(component.attributeInputForm.value).toBe(''); + }); + + it('should raise event in case input was changed', () => { + spyOn(component.inputChange, 'emit').and.callThrough(); + component.onChange(); + expect(component.inputChange.emit).toHaveBeenCalled(); + }); + + it('should raise no event in case input was changed and control is invalid', () => { + spyOn(component.inputChange, 'emit').and.callThrough(); + component.ngOnInit(); + component.attributeInputForm.setValue('122A23'); + component.onChange(); + expect(component.inputChange.emit).toHaveBeenCalledTimes(0); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.ts new file mode 100644 index 00000000000..0dba1d1bc05 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.component.ts @@ -0,0 +1,117 @@ +import { getLocaleId } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + isDevMode, + OnInit, + Output, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfigFormUpdateEvent } from '../../../form/configurator-form.event'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +import { ConfiguratorAttributeNumericInputFieldService } from './configurator-attribute-numeric-input-field.component.service'; + +@Component({ + selector: 'cx-configurator-attribute-numeric-input-field', + templateUrl: './configurator-attribute-numeric-input-field.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorAttributeNumericInputFieldComponent + extends ConfiguratorAttributeBaseComponent + implements OnInit { + attributeInputForm: FormControl; + numericFormatPattern: string; + locale: string; + + @Input() attribute: Configurator.Attribute; + @Input() group: string; + @Input() ownerKey: string; + @Input() language: string; + + @Output() inputChange = new EventEmitter(); + + constructor( + protected configAttributeNumericInputFieldService: ConfiguratorAttributeNumericInputFieldService + ) { + super(); + } + + /** + * Do we need to display a validation message + */ + mustDisplayValidationMessage(): boolean { + const wrongFormat: boolean = + (this.attributeInputForm.dirty || this.attributeInputForm.touched) && + this.attributeInputForm.errors?.wrongFormat; + + return wrongFormat; + } + + ngOnInit() { + //locales are available as 'languages' in the commerce backend + this.locale = this.getInstalledLocale(this.language); + + this.attributeInputForm = new FormControl('', [ + this.configAttributeNumericInputFieldService.getNumberFormatValidator( + this.locale, + this.attribute.numDecimalPlaces, + this.attribute.numTotalLength, + this.attribute.negativeAllowed + ), + ]); + const numDecimalPlaces = this.attribute.numDecimalPlaces; + this.numericFormatPattern = this.configAttributeNumericInputFieldService.getPatternForValidationMessage( + numDecimalPlaces, + this.attribute.numTotalLength, + this.attribute.negativeAllowed, + this.locale + ); + if (this.attribute.userInput) { + this.attributeInputForm.setValue(this.attribute.userInput); + } + } + + /** + * Hit when user input was changed + */ + onChange(): void { + const event: ConfigFormUpdateEvent = this.createEventFromInput(); + + if (!this.attributeInputForm.invalid) { + this.inputChange.emit(event); + } + } + + protected createEventFromInput(): ConfigFormUpdateEvent { + return { + ownerKey: this.ownerKey, + changedAttribute: { + name: this.attribute.name, + userInput: this.attributeInputForm.value, + uiType: this.attribute.uiType, + groupId: this.attribute.groupId, + }, + }; + } + + protected getInstalledLocale(locale: string): string { + try { + getLocaleId(locale); + return locale; + } catch { + this.reportMissingLocaleData(locale); + return 'en'; + } + } + + protected reportMissingLocaleData(lang: string): void { + if (isDevMode()) { + console.warn( + `ConfigAttributeNumericInputFieldComponent: No locale data registered for '${lang}' (see https://angular.io/api/common/registerLocaleData).` + ); + } + } +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.module.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.module.ts new file mode 100644 index 00000000000..1e2a5b06aa7 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { I18nModule } from '@spartacus/core'; +import { KeyboardFocusModule } from '@spartacus/storefront'; +import { ConfiguratorAttributeNumericInputFieldComponent } from './configurator-attribute-numeric-input-field.component'; + +@NgModule({ + imports: [ + KeyboardFocusModule, + FormsModule, + ReactiveFormsModule, + CommonModule, + I18nModule, + ], + declarations: [ConfiguratorAttributeNumericInputFieldComponent], + exports: [ConfiguratorAttributeNumericInputFieldComponent], + entryComponents: [ConfiguratorAttributeNumericInputFieldComponent], +}) +export class ConfiguratorAttributeNumericInputFieldModule {} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/index.ts new file mode 100644 index 00000000000..2346db65186 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/numeric-input-field/index.ts @@ -0,0 +1,3 @@ +export * from './configurator-attribute-numeric-input-field.component'; +export * from './configurator-attribute-numeric-input-field.component.service'; +export * from './configurator-attribute-numeric-input-field.module'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.component.html new file mode 100644 index 00000000000..f2e1f965aa1 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.component.html @@ -0,0 +1,39 @@ +
+ {{ attribute.label }} +
+
+ + +
+
+
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.component.spec.ts new file mode 100644 index 00000000000..ec6a9ca8938 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.component.spec.ts @@ -0,0 +1,96 @@ +import { ChangeDetectionStrategy, Directive, Input } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ConfiguratorGroupsService } from '../../../../core/facade/configurator-groups.service'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfiguratorStorefrontUtilsService } from '../../../service/configurator-storefront-utils.service'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +import { ConfiguratorAttributeRadioButtonComponent } from './configurator-attribute-radio-button.component'; + +class MockGroupService {} + +@Directive({ + selector: '[cxFocus]', +}) +export class MockFocusDirective { + @Input('cxFocus') protected config; +} + +describe('ConfigAttributeRadioButtonComponent', () => { + let component: ConfiguratorAttributeRadioButtonComponent; + let fixture: ComponentFixture; + const ownerKey = 'theOwnerKey'; + const name = 'theName'; + const groupId = 'theGroupId'; + const changedSelectedValue = 'changedSelectedValue'; + const initialSelectedValue = 'initialSelectedValue'; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + ConfiguratorAttributeRadioButtonComponent, + MockFocusDirective, + ], + imports: [ReactiveFormsModule], + providers: [ + ConfiguratorAttributeBaseComponent, + ConfiguratorStorefrontUtilsService, + { + provide: ConfiguratorGroupsService, + useClass: MockGroupService, + }, + ], + }) + .overrideComponent(ConfiguratorAttributeRadioButtonComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent( + ConfiguratorAttributeRadioButtonComponent + ); + component = fixture.componentInstance; + component.attribute = { + name: name, + attrCode: 444, + uiType: Configurator.UiType.RADIOBUTTON, + selectedSingleValue: initialSelectedValue, + groupId: groupId, + quantity: 1, + }; + component.ownerKey = ownerKey; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set selectedSingleValue on init', () => { + expect(component.attributeRadioButtonForm.value).toEqual( + initialSelectedValue + ); + }); + + it('should call emit of selectionChange onSelect', () => { + spyOn(component.selectionChange, 'emit').and.callThrough(); + component.onSelect(changedSelectedValue); + expect(component.selectionChange.emit).toHaveBeenCalledWith( + jasmine.objectContaining({ + ownerKey: ownerKey, + changedAttribute: jasmine.objectContaining({ + name: name, + selectedSingleValue: changedSelectedValue, + uiType: Configurator.UiType.RADIOBUTTON, + groupId: groupId, + }), + }) + ); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.component.ts new file mode 100644 index 00000000000..69b2e0ffc8f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.component.ts @@ -0,0 +1,51 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfigFormUpdateEvent } from '../../../form/configurator-form.event'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; + +@Component({ + selector: 'cx-configurator-attribute-radio-button', + templateUrl: './configurator-attribute-radio-button.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorAttributeRadioButtonComponent + extends ConfiguratorAttributeBaseComponent + implements OnInit { + attributeRadioButtonForm = new FormControl(''); + + @Input() attribute: Configurator.Attribute; + @Input() ownerKey: string; + + @Output() selectionChange = new EventEmitter(); + + ngOnInit(): void { + this.attributeRadioButtonForm.setValue(this.attribute.selectedSingleValue); + } + + /** + * Submits a value. + * + * @param {string} value - Selected value + */ + onSelect(value: string): void { + const event: ConfigFormUpdateEvent = { + ownerKey: this.ownerKey, + changedAttribute: { + name: this.attribute.name, + selectedSingleValue: value, + uiType: this.attribute.uiType, + groupId: this.attribute.groupId, + }, + }; + + this.selectionChange.emit(event); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.module.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.module.ts new file mode 100644 index 00000000000..e4140bbae89 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/configurator-attribute-radio-button.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { I18nModule } from '@spartacus/core'; +import { KeyboardFocusModule } from '@spartacus/storefront'; +import { ConfiguratorAttributeRadioButtonComponent } from './configurator-attribute-radio-button.component'; + +@NgModule({ + imports: [ + KeyboardFocusModule, + FormsModule, + ReactiveFormsModule, + CommonModule, + I18nModule, + ], + declarations: [ConfiguratorAttributeRadioButtonComponent], + exports: [ConfiguratorAttributeRadioButtonComponent], + entryComponents: [ConfiguratorAttributeRadioButtonComponent], +}) +export class ConfiguratorAttributeRadioButtonModule {} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/index.ts new file mode 100644 index 00000000000..ff4efc917e6 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/radio-button/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-attribute-radio-button.component'; +export * from './configurator-attribute-radio-button.module'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.component.html new file mode 100644 index 00000000000..77d500b6c01 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.component.html @@ -0,0 +1,47 @@ +
+ + +
+ {{ value.valueDisplay }} +
+
+
+ +
+ {{ attribute.selectedSingleValue }} +
+
+
+ {{ attribute.userInput }} +
+
+
+
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.component.spec.ts new file mode 100644 index 00000000000..6fb3613c1a5 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.component.spec.ts @@ -0,0 +1,102 @@ +import { ChangeDetectionStrategy } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { CommonConfiguratorTestUtilsService } from '../../../../../common/shared/testing/common-configurator-test-utils.service'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +import { ConfiguratorAttributeReadOnlyComponent } from './configurator-attribute-read-only.component'; + +describe('ConfigAttributeReadOnlyComponent', () => { + let component: ConfiguratorAttributeReadOnlyComponent; + let fixture: ComponentFixture; + let htmlElem: HTMLElement; + const myValues: Configurator.Value[] = [ + { + valueCode: 'val1', + valueDisplay: 'Value 1', + selected: false, + }, + { + valueCode: 'val2', + valueDisplay: 'Value 2', + selected: true, + }, + ]; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ConfiguratorAttributeReadOnlyComponent], + imports: [ReactiveFormsModule], + providers: [ConfiguratorAttributeBaseComponent], + }) + .overrideComponent(ConfiguratorAttributeReadOnlyComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorAttributeReadOnlyComponent); + component = fixture.componentInstance; + htmlElem = fixture.nativeElement; + component.attribute = { + name: 'valueName', + attrCode: 444, + uiType: Configurator.UiType.READ_ONLY, + selectedSingleValue: 'selectedValue', + quantity: 1, + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display selectedSingleValue for attribute without domain', () => { + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-read-only-label' + ); + CommonConfiguratorTestUtilsService.expectElementToContainText( + expect, + htmlElem, + '.cx-read-only-label', + 'selectedValue' + ); + }); + + it('should display valueDisplay of selected value for attribute with domain', () => { + myValues[0].selected = false; + component.attribute.values = myValues; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-read-only-label' + ); + CommonConfiguratorTestUtilsService.expectElementToContainText( + expect, + htmlElem, + '.cx-read-only-label', + 'Value 2' + ); + }); + + it('should display valueDisplay of all selected values for attribute with domain', () => { + myValues[0].selected = true; + component.attribute.values = myValues; + fixture.detectChanges(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-read-only-label' + ); + expect(htmlElem.querySelectorAll('.cx-read-only-label').length).toBe(2); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.component.ts new file mode 100644 index 00000000000..5ceba21cabc --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +@Component({ + selector: 'cx-configurator-attribute-read-only', + templateUrl: './configurator-attribute-read-only.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorAttributeReadOnlyComponent extends ConfiguratorAttributeBaseComponent { + @Input() attribute: Configurator.Attribute; + @Input() group: String; +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.module.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.module.ts new file mode 100644 index 00000000000..768740d4030 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/configurator-attribute-read-only.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { I18nModule } from '@spartacus/core'; +import { KeyboardFocusModule } from '@spartacus/storefront'; +import { ConfiguratorAttributeReadOnlyComponent } from './configurator-attribute-read-only.component'; + +@NgModule({ + imports: [ + KeyboardFocusModule, + FormsModule, + ReactiveFormsModule, + CommonModule, + I18nModule, + ], + declarations: [ConfiguratorAttributeReadOnlyComponent], + exports: [ConfiguratorAttributeReadOnlyComponent], + entryComponents: [ConfiguratorAttributeReadOnlyComponent], +}) +export class ConfiguratorAttributeReadOnlyModule {} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/index.ts new file mode 100644 index 00000000000..95ee0549378 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/read-only/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-attribute-read-only.component'; +export * from './configurator-attribute-read-only.module'; diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.html new file mode 100644 index 00000000000..700d2ede235 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.html @@ -0,0 +1,53 @@ +
+
+ +
+ +
+
+
diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.spec.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.spec.ts new file mode 100644 index 00000000000..c819fab26a6 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.spec.ts @@ -0,0 +1,149 @@ +import { ChangeDetectionStrategy, Directive, Input } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { ConfiguratorGroupsService } from '../../../../core/facade/configurator-groups.service'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfiguratorStorefrontUtilsService } from '../../../service/configurator-storefront-utils.service'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; +import { ConfiguratorAttributeSingleSelectionImageComponent } from './configurator-attribute-single-selection-image.component'; + +class MockGroupService {} + +@Directive({ + selector: '[cxFocus]', +}) +export class MockFocusDirective { + @Input('cxFocus') protected config; +} + +describe('ConfigAttributeSingleSelectionImageComponent', () => { + let component: ConfiguratorAttributeSingleSelectionImageComponent; + let fixture: ComponentFixture; + let htmlElem: HTMLElement; + const ownerKey = 'theOwnerKey'; + const groupId = 'testGroup'; + const attributeName = 'attributeName'; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + ConfiguratorAttributeSingleSelectionImageComponent, + MockFocusDirective, + ], + imports: [ReactiveFormsModule, NgSelectModule], + providers: [ + ConfiguratorAttributeBaseComponent, + ConfiguratorStorefrontUtilsService, + { + provide: ConfiguratorGroupsService, + useClass: MockGroupService, + }, + ], + }) + .overrideComponent(ConfiguratorAttributeSingleSelectionImageComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + function createImage(url: string, altText: string): Configurator.Image { + const image: Configurator.Image = { + url: url, + altText: altText, + }; + return image; + } + + function createValue( + code: string, + name: string, + isSelected: boolean, + images: Configurator.Image[] + ): Configurator.Value { + const value: Configurator.Value = { + valueCode: code, + name: name, + selected: isSelected, + images: images, + }; + return value; + } + + beforeEach(() => { + const image = createImage('url', 'altText'); + const images: Configurator.Image[] = [image, image, image]; + const value1 = createValue('1', 'val1', false, images); + const value2 = createValue('2', 'val2', false, images); + const value3 = createValue('3', 'val3', false, images); + const values: Configurator.Value[] = [value1, value2, value3]; + + fixture = TestBed.createComponent( + ConfiguratorAttributeSingleSelectionImageComponent + ); + component = fixture.componentInstance; + htmlElem = fixture.nativeElement; + + component.attribute = { + name: attributeName, + attrCode: 444, + uiType: Configurator.UiType.SINGLE_SELECTION_IMAGE, + required: false, + selectedSingleValue: values[2].valueCode, + groupId: groupId, + values: values, + }; + component.ownerKey = ownerKey; + fixture.detectChanges(); + }); + + it('should create a component', () => { + expect(component).toBeTruthy(); + }); + + it('should render 3 images', () => { + component.ngOnInit(); + fixture.detectChanges(); + + expect(htmlElem.querySelectorAll('.cx-img').length).toBe(3); + }); + + it('should init with val3', () => { + fixture.detectChanges(); + expect(component.attributeRadioButtonForm.value).toEqual( + component.attribute.values[2].valueCode + ); + }); + + it('should select another single selection image value', () => { + const singleSelectionImageId = + '#cx-configurator--single_selection_image--' + + component.attribute.name + + '--' + + component.attribute.values[1].valueCode + + '-input'; + const valueToSelect = fixture.debugElement.query( + By.css(singleSelectionImageId) + ).nativeElement; + expect(valueToSelect.checked).toBe(false); + spyOn(component.selectionChange, 'emit').and.callThrough(); + component.onClick(component.attribute.values[1].valueCode); + fixture.detectChanges(); + expect(component.selectionChange.emit).toHaveBeenCalledWith( + jasmine.objectContaining({ + ownerKey: ownerKey, + changedAttribute: jasmine.objectContaining({ + name: attributeName, + selectedSingleValue: component.attribute.values[1].valueCode, + uiType: Configurator.UiType.SINGLE_SELECTION_IMAGE, + groupId: groupId, + }), + }) + ); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.ts new file mode 100644 index 00000000000..fbacaeb6644 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.ts @@ -0,0 +1,50 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Configurator } from '../../../../core/model/configurator.model'; +import { ConfigFormUpdateEvent } from '../../../form/configurator-form.event'; +import { ConfiguratorAttributeBaseComponent } from '../base/configurator-attribute-base.component'; + +@Component({ + selector: 'cx-configurator-attribute-single-selection-image', + templateUrl: './configurator-attribute-single-selection-image.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorAttributeSingleSelectionImageComponent + extends ConfiguratorAttributeBaseComponent + implements OnInit { + attributeRadioButtonForm = new FormControl(''); + + @Input() attribute: Configurator.Attribute; + @Input() ownerKey: string; + + @Output() selectionChange = new EventEmitter(); + + ngOnInit(): void { + this.attributeRadioButtonForm.setValue(this.attribute.selectedSingleValue); + } + + /** + * Submits a value. + * + * @param {string} value - Selected value + */ + onClick(value: string): void { + const event: ConfigFormUpdateEvent = { + ownerKey: this.ownerKey, + changedAttribute: { + name: this.attribute.name, + selectedSingleValue: value, + uiType: this.attribute.uiType, + groupId: this.attribute.groupId, + }, + }; + this.selectionChange.emit(event); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.module.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.module.ts new file mode 100644 index 00000000000..50ea5474cec --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { I18nModule } from '@spartacus/core'; +import { KeyboardFocusModule } from '@spartacus/storefront'; +import { ConfiguratorAttributeSingleSelectionImageComponent } from './configurator-attribute-single-selection-image.component'; + +@NgModule({ + imports: [ + KeyboardFocusModule, + FormsModule, + ReactiveFormsModule, + CommonModule, + I18nModule, + ], + declarations: [ConfiguratorAttributeSingleSelectionImageComponent], + exports: [ConfiguratorAttributeSingleSelectionImageComponent], + entryComponents: [ConfiguratorAttributeSingleSelectionImageComponent], +}) +export class ConfiguratorAttributeSingleSelectionImageModule {} diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/index.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/index.ts new file mode 100644 index 00000000000..ab6856928d4 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-attribute-single-selection-image.component'; +export * from './configurator-attribute-single-selection-image.module'; diff --git a/feature-libs/product-configurator/rulebased/components/config/default-message-config.ts b/feature-libs/product-configurator/rulebased/components/config/default-message-config.ts new file mode 100644 index 00000000000..905ff7e8aad --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/config/default-message-config.ts @@ -0,0 +1,9 @@ +import { MessageConfig } from './message-config'; + +export const DefaultMessageConfig: MessageConfig = { + productConfigurator: { + updateConfigurationMessage: { + waitingTime: 1000, + }, + }, +}; diff --git a/feature-libs/product-configurator/rulebased/components/config/index.ts b/feature-libs/product-configurator/rulebased/components/config/index.ts new file mode 100644 index 00000000000..773052c20b0 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/config/index.ts @@ -0,0 +1 @@ +export * from './message-config'; diff --git a/feature-libs/product-configurator/rulebased/components/config/message-config.ts b/feature-libs/product-configurator/rulebased/components/config/message-config.ts new file mode 100644 index 00000000000..31676d6ae4a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/config/message-config.ts @@ -0,0 +1,7 @@ +export abstract class MessageConfig { + productConfigurator: { + updateConfigurationMessage?: { + waitingTime?: number; + }; + }; +} diff --git a/feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.component.html b/feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.component.html new file mode 100644 index 00000000000..6235c62f457 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.component.html @@ -0,0 +1,4 @@ + + + {{ currentGroup.name }} + diff --git a/feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.component.spec.ts b/feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.component.spec.ts new file mode 100644 index 00000000000..52d422cc3d5 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.component.spec.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ICON_TYPE } from '@spartacus/storefront'; +import { Configurator } from '../../core/model/configurator.model'; +import { ConfiguratorConflictDescriptionComponent } from './configurator-conflict-description.component'; + +@Component({ + selector: 'cx-icon', + template: '', +}) +class MockCxIconComponent { + @Input() type: ICON_TYPE; +} + +describe('ConfigurationConflictDescriptionComponent', () => { + let component: ConfiguratorConflictDescriptionComponent; + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + ConfiguratorConflictDescriptionComponent, + MockCxIconComponent, + ], + imports: [], + providers: [], + }) + .overrideComponent(ConfiguratorConflictDescriptionComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorConflictDescriptionComponent); + component = fixture.componentInstance; + }); + it('should create component', () => { + expect(component).toBeDefined(); + }); + + it('should return true for conflict group', () => { + const conflictGroup = { groupType: Configurator.GroupType.CONFLICT_GROUP }; + expect(component.displayConflictDescription(conflictGroup)).toBe(true); + const group = { groupType: Configurator.GroupType.ATTRIBUTE_GROUP }; + expect(component.displayConflictDescription(group)).toBe(false); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.component.ts b/feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.component.ts new file mode 100644 index 00000000000..76be99ddc7e --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.component.ts @@ -0,0 +1,34 @@ +import { + ChangeDetectionStrategy, + Component, + HostBinding, + Input, +} from '@angular/core'; +import { ICON_TYPE } from '@spartacus/storefront'; +import { Configurator } from '../../core/model/configurator.model'; + +@Component({ + selector: 'cx-configurator-conflict-description', + templateUrl: './configurator-conflict-description.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorConflictDescriptionComponent { + @Input() currentGroup: Configurator.Group; + + groupType = Configurator.GroupType; + iconTypes = ICON_TYPE; + + @HostBinding('tabindex') tabindex = '0'; + + constructor() {} + + /** + * Verifies whether the conflict description should be displayed for the current group. + * + * @param {Configurator.Group} group - Current group + * @return {boolean} - 'True' if the conflict description should be displayed, otherwise 'false'. + */ + displayConflictDescription(group: Configurator.Group): boolean { + return group.groupType === Configurator.GroupType.CONFLICT_GROUP; + } +} diff --git a/feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.module.ts b/feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.module.ts new file mode 100644 index 00000000000..e2a885d30d7 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/conflict-description/configurator-conflict-description.module.ts @@ -0,0 +1,12 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { IconModule } from '@spartacus/storefront'; +import { ConfiguratorConflictDescriptionComponent } from './configurator-conflict-description.component'; + +@NgModule({ + imports: [CommonModule, IconModule], + declarations: [ConfiguratorConflictDescriptionComponent], + exports: [ConfiguratorConflictDescriptionComponent], + entryComponents: [ConfiguratorConflictDescriptionComponent], +}) +export class ConfiguratorConflictDescriptionModule {} diff --git a/feature-libs/product-configurator/rulebased/components/conflict-description/index.ts b/feature-libs/product-configurator/rulebased/components/conflict-description/index.ts new file mode 100644 index 00000000000..fb417082939 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/conflict-description/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-conflict-description.component'; +export * from './configurator-conflict-description.module'; diff --git a/feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.component.html b/feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.component.html new file mode 100644 index 00000000000..73ab3f50d77 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.component.html @@ -0,0 +1,12 @@ + +
+ {{ + 'configurator.conflict.suggestionTitle' + | cxTranslate: { number: suggestionNumber + 1 } + }} +
+ {{ + 'configurator.conflict.suggestionText' + | cxTranslate: { attribute: attribute.label } + }} +
diff --git a/feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.component.spec.ts b/feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.component.spec.ts new file mode 100644 index 00000000000..89aa3b234b7 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.component.spec.ts @@ -0,0 +1,51 @@ +import { ChangeDetectionStrategy } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { Configurator } from '../../core/model/configurator.model'; +import { ConfiguratorConflictSuggestionComponent } from './configurator-conflict-suggestion.component'; + +describe('ConfigurationConflictSuggestionComponent', () => { + let component: ConfiguratorConflictSuggestionComponent; + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ConfiguratorConflictSuggestionComponent], + imports: [], + providers: [], + }) + .overrideComponent(ConfiguratorConflictSuggestionComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorConflictSuggestionComponent); + component = fixture.componentInstance; + }); + + it('should create component', () => { + expect(component).toBeDefined(); + }); + + it('should return true for conflict group with more than one attribute', () => { + const conflictGroup1 = { groupType: Configurator.GroupType.CONFLICT_GROUP }; + expect(component.displayConflictSuggestion(conflictGroup1)).toBe(false); + const conflictGroup2 = { + groupType: Configurator.GroupType.CONFLICT_GROUP, + attributes: [{ name: '1' }], + }; + expect(component.displayConflictSuggestion(conflictGroup2)).toBe(false); + const conflictGroup3 = { + groupType: Configurator.GroupType.CONFLICT_GROUP, + attributes: [{ name: '1' }, { name: '2' }], + }; + expect(component.displayConflictSuggestion(conflictGroup3)).toBe(true); + const group = { groupType: Configurator.GroupType.ATTRIBUTE_GROUP }; + expect(component.displayConflictSuggestion(group)).toBe(false); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.component.ts b/feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.component.ts new file mode 100644 index 00000000000..9bcb7a73032 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.component.ts @@ -0,0 +1,37 @@ +import { + ChangeDetectionStrategy, + Component, + HostBinding, + Input, +} from '@angular/core'; +import { Configurator } from '../../core/model/configurator.model'; + +@Component({ + selector: 'cx-configurator-conflict-suggestion', + templateUrl: './configurator-conflict-suggestion.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorConflictSuggestionComponent { + @Input() currentGroup: Configurator.Group; + @Input() attribute: Configurator.Attribute; + @Input() suggestionNumber: number; + + groupType = Configurator.GroupType; + + @HostBinding('tabindex') tabindex = '0'; + + constructor() {} + + /** + * Verifies whether the conflict suggestion should be displayed for the current group. + * + * @param {Configurator.Group} group - Current group + * @return {boolean} - 'True' if the conflict description should be displayed, otherwise 'false'. + */ + displayConflictSuggestion(group: Configurator.Group): boolean { + return ( + group.groupType === Configurator.GroupType.CONFLICT_GROUP && + group.attributes?.length > 1 + ); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.module.ts b/feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.module.ts new file mode 100644 index 00000000000..955adbe3504 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/conflict-suggestion/configurator-conflict-suggestion.module.ts @@ -0,0 +1,12 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { I18nModule } from '@spartacus/core'; +import { ConfiguratorConflictSuggestionComponent } from './configurator-conflict-suggestion.component'; + +@NgModule({ + imports: [CommonModule, I18nModule], + declarations: [ConfiguratorConflictSuggestionComponent], + exports: [ConfiguratorConflictSuggestionComponent], + entryComponents: [ConfiguratorConflictSuggestionComponent], +}) +export class ConfiguratorConflictSuggestionModule {} diff --git a/feature-libs/product-configurator/rulebased/components/conflict-suggestion/index.ts b/feature-libs/product-configurator/rulebased/components/conflict-suggestion/index.ts new file mode 100644 index 00000000000..cc6cf06b744 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/conflict-suggestion/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-conflict-suggestion.component'; +export * from './configurator-conflict-suggestion.module'; diff --git a/feature-libs/product-configurator/rulebased/components/form/configurator-form.component.html b/feature-libs/product-configurator/rulebased/components/form/configurator-form.component.html new file mode 100644 index 00000000000..7a3d248d93a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/form/configurator-form.component.html @@ -0,0 +1,101 @@ + + + + + +
+ + + + + + + + + + + + + + + + + {{ + 'configurator.attribute.notSupported' | cxTranslate + }} + +
+
+
diff --git a/feature-libs/product-configurator/rulebased/components/form/configurator-form.component.spec.ts b/feature-libs/product-configurator/rulebased/components/form/configurator-form.component.spec.ts new file mode 100644 index 00000000000..f39a07c52b0 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/form/configurator-form.component.spec.ts @@ -0,0 +1,405 @@ +import { ChangeDetectionStrategy, Component, Input, Type } from '@angular/core'; +import { async, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterState } from '@angular/router'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { + I18nTestingModule, + LanguageService, + RoutingService, +} from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { ICON_TYPE } from '@spartacus/storefront'; +import { cold } from 'jasmine-marbles'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { ConfiguratorGroupsService } from '../../core/facade/configurator-groups.service'; +import { Configurator } from '../../core/model/configurator.model'; +import * as ConfigurationTestData from '../../shared/testing/configurator-test-data'; +import { ConfiguratorAttributeFooterComponent } from '../attribute/footer/configurator-attribute-footer.component'; +import { ConfiguratorAttributeHeaderComponent } from '../attribute/header/configurator-attribute-header.component'; +import { ConfiguratorAttributeCheckBoxListComponent } from '../attribute/types/checkbox-list/configurator-attribute-checkbox-list.component'; +import { ConfiguratorAttributeCheckBoxComponent } from '../attribute/types/checkbox/configurator-attribute-checkbox.component'; +import { ConfiguratorAttributeDropDownComponent } from '../attribute/types/drop-down/configurator-attribute-drop-down.component'; +import { ConfiguratorAttributeInputFieldComponent } from '../attribute/types/input-field/configurator-attribute-input-field.component'; +import { ConfiguratorAttributeMultiSelectionImageComponent } from '../attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component'; +import { ConfiguratorAttributeRadioButtonComponent } from '../attribute/types/radio-button/configurator-attribute-radio-button.component'; +import { ConfiguratorAttributeReadOnlyComponent } from '../attribute/types/read-only/configurator-attribute-read-only.component'; +import { ConfiguratorAttributeSingleSelectionImageComponent } from '../attribute/types/single-selection-image/configurator-attribute-single-selection-image.component'; +import { ConfiguratorFormComponent } from './configurator-form.component'; + +const PRODUCT_CODE = 'CONF_LAPTOP'; +const CONFIGURATOR_ROUTE = 'configureCPQCONFIGURATOR'; + +const mockRouterState: any = { + state: { + params: { + entityKey: PRODUCT_CODE, + ownerType: CommonConfigurator.OwnerType.PRODUCT, + }, + semanticRoute: CONFIGURATOR_ROUTE, + queryParams: {}, + }, +}; + +const owner: CommonConfigurator.Owner = { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.PRODUCT, +}; +const groups: Configurator.Group[] = + ConfigurationTestData.productConfiguration.groups; + +const configRead: Configurator.Configuration = { + configId: 'a', + consistent: true, + complete: true, + productCode: PRODUCT_CODE, + owner: owner, + groups: groups, +}; + +const configRead2: Configurator.Configuration = { + configId: 'b', + consistent: true, + complete: true, + productCode: PRODUCT_CODE, + owner: owner, + groups: groups, +}; + +@Component({ + selector: 'cx-icon', + template: '', +}) +class MockCxIconComponent { + @Input() type: ICON_TYPE; +} + +let routerStateObservable = null; +let configurationCreateObservable = null; +let currentGroupObservable = null; +let isConfigurationLoadingObservable = null; +let hasConfigurationConflictsObservable = null; + +class MockRoutingService { + getRouterState(): Observable { + return routerStateObservable; + } +} + +class MockConfiguratorCommonsService { + getOrCreateConfiguration(): Observable { + return configurationCreateObservable; + } + removeConfiguration(): void {} + updateConfiguration(): void {} + + isConfigurationLoading(): Observable { + return isConfigurationLoadingObservable; + } + hasConflicts(): Observable { + return hasConfigurationConflictsObservable; + } +} +class MockConfiguratorGroupsService { + getCurrentGroup(): Observable { + return currentGroupObservable; + } + getNextGroup(): Observable { + return of(''); + } + getPreviousGroup(): Observable { + return of(''); + } + subscribeToUpdateConfiguration() {} + setGroupStatusVisited(): void {} + navigateToConflictSolver(): void {} + navigateToFirstIncompleteGroup(): void {} + isConflictGroupType() {} +} +function checkConfigurationObs( + routerMarbels: string, + configurationServiceMarbels: string, + expectedMarbels: string +) { + routerStateObservable = cold(routerMarbels, { + a: mockRouterState, + }); + configurationCreateObservable = cold(configurationServiceMarbels, { + x: configRead, + y: configRead2, + }); + + const fixture = TestBed.createComponent(ConfiguratorFormComponent); + const component = fixture.componentInstance; + expect(component.configuration$).toBeObservable( + cold(expectedMarbels, { x: configRead, y: configRead2 }) + ); +} +function checkCurrentGroupObs( + routerMarbels: string, + groupMarbels: string, + expectedMarbels: string +) { + routerStateObservable = cold(routerMarbels, { + a: mockRouterState, + }); + currentGroupObservable = cold(groupMarbels, { + u: groups[0], + v: groups[1], + }); + const fixture = TestBed.createComponent(ConfiguratorFormComponent); + const component = fixture.componentInstance; + expect(component.currentGroup$).toBeObservable( + cold(expectedMarbels, { + u: groups[0], + v: groups[1], + }) + ); +} +describe('ConfigurationFormComponent', () => { + let configuratorCommonsService; + let configuratorUtils: CommonConfiguratorUtilsService; + let configurationCommonsService: ConfiguratorCommonsService; + let configuratorGroupsService: ConfiguratorGroupsService; + let mockLanguageService; + beforeEach(async(() => { + mockLanguageService = { + getAll: () => of([]), + getActive: jasmine.createSpy().and.returnValue(of('en')), + }; + TestBed.configureTestingModule({ + imports: [I18nTestingModule, ReactiveFormsModule, NgSelectModule], + declarations: [ + ConfiguratorFormComponent, + ConfiguratorAttributeHeaderComponent, + ConfiguratorAttributeFooterComponent, + ConfiguratorAttributeRadioButtonComponent, + ConfiguratorAttributeInputFieldComponent, + ConfiguratorAttributeDropDownComponent, + ConfiguratorAttributeReadOnlyComponent, + + ConfiguratorAttributeCheckBoxComponent, + ConfiguratorAttributeCheckBoxListComponent, + ConfiguratorAttributeMultiSelectionImageComponent, + ConfiguratorAttributeSingleSelectionImageComponent, + MockCxIconComponent, + ], + providers: [ + { + provide: RoutingService, + useClass: MockRoutingService, + }, + + { + provide: ConfiguratorCommonsService, + useClass: MockConfiguratorCommonsService, + }, + + { + provide: ConfiguratorGroupsService, + useClass: MockConfiguratorGroupsService, + }, + { provide: LanguageService, useValue: mockLanguageService }, + ], + }) + .overrideComponent(ConfiguratorAttributeHeaderComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + })); + beforeEach(() => { + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + configurationCommonsService = TestBed.inject( + ConfiguratorCommonsService as Type + ); + configuratorGroupsService = TestBed.inject( + ConfiguratorGroupsService as Type + ); + spyOn( + configurationCommonsService, + 'isConfigurationLoading' + ).and.callThrough(); + spyOn(configuratorGroupsService, 'setGroupStatusVisited').and.callThrough(); + + configuratorUtils.setOwnerKey(owner); + configuratorCommonsService = TestBed.inject( + ConfiguratorCommonsService as Type + ); + isConfigurationLoadingObservable = of(false); + hasConfigurationConflictsObservable = of(false); + }); + + it('should not enforce a reload of the configuration per default', () => { + spyOn(configuratorCommonsService, 'removeConfiguration').and.callThrough(); + mockRouterState.state.queryParams = { forceReload: 'false' }; + routerStateObservable = of(mockRouterState); + const fixture = TestBed.createComponent(ConfiguratorFormComponent); + const component = fixture.componentInstance; + component.ngOnInit(); + expect( + configuratorCommonsService.removeConfiguration + ).toHaveBeenCalledTimes(0); + }); + + it('should enforce a reload of the configuration by removing the current one in case the router requires this', () => { + spyOn(configuratorCommonsService, 'removeConfiguration').and.callThrough(); + routerStateObservable = of({ + ...mockRouterState, + state: { + ...mockRouterState.state, + queryParams: { forceReload: 'true' }, + }, + }); + const fixture = TestBed.createComponent(ConfiguratorFormComponent); + const component = fixture.componentInstance; + component.ngOnInit(); + + expect( + configuratorCommonsService.removeConfiguration + ).toHaveBeenCalledTimes(1); + }); + + it('should call configurator group service to check group type', () => { + routerStateObservable = of(mockRouterState); + spyOn(configuratorGroupsService, 'isConflictGroupType').and.callThrough(); + const fixture = TestBed.createComponent(ConfiguratorFormComponent); + const component = fixture.componentInstance; + component.isConflictGroupType(Configurator.GroupType.CONFLICT_GROUP); + expect(configuratorGroupsService.isConflictGroupType).toHaveBeenCalledWith( + Configurator.GroupType.CONFLICT_GROUP + ); + }); + + describe('resolve issues navigation', () => { + it('should go to neither conflict solver nor first incomplete group', () => { + spyOn( + configuratorGroupsService, + 'navigateToConflictSolver' + ).and.callThrough(); + spyOn( + configuratorGroupsService, + 'navigateToFirstIncompleteGroup' + ).and.callThrough(); + routerStateObservable = of({ + ...mockRouterState, + }); + + const fixture = TestBed.createComponent(ConfiguratorFormComponent); + const component = fixture.componentInstance; + component.ngOnInit(); + + expect( + configuratorGroupsService.navigateToConflictSolver + ).toHaveBeenCalledTimes(0); + expect( + configuratorGroupsService.navigateToFirstIncompleteGroup + ).toHaveBeenCalledTimes(0); + }); + + it('should go to conflict solver in case the router requires this - has conflicts', () => { + spyOn( + configuratorGroupsService, + 'navigateToConflictSolver' + ).and.callThrough(); + spyOn( + configuratorGroupsService, + 'navigateToFirstIncompleteGroup' + ).and.callThrough(); + routerStateObservable = of({ + ...mockRouterState, + state: { + ...mockRouterState.state, + queryParams: { resolveIssues: 'true' }, + }, + }); + hasConfigurationConflictsObservable = of(true); + const fixture = TestBed.createComponent(ConfiguratorFormComponent); + const component = fixture.componentInstance; + component.ngOnInit(); + + expect( + configuratorGroupsService.navigateToConflictSolver + ).toHaveBeenCalledTimes(1); + expect( + configuratorGroupsService.navigateToFirstIncompleteGroup + ).toHaveBeenCalledTimes(0); + }); + + it('should go to first incomplete group in case the router requires this - has no conflicts', () => { + spyOn( + configuratorGroupsService, + 'navigateToConflictSolver' + ).and.callThrough(); + spyOn( + configuratorGroupsService, + 'navigateToFirstIncompleteGroup' + ).and.callThrough(); + routerStateObservable = of({ + ...mockRouterState, + state: { + ...mockRouterState.state, + queryParams: { resolveIssues: 'true' }, + }, + }); + const fixture = TestBed.createComponent(ConfiguratorFormComponent); + const component = fixture.componentInstance; + component.ngOnInit(); + + expect( + configuratorGroupsService.navigateToConflictSolver + ).toHaveBeenCalledTimes(0); + expect( + configuratorGroupsService.navigateToFirstIncompleteGroup + ).toHaveBeenCalledTimes(1); + }); + }); + + it('should only get the minimum needed 2 emissions of product configurations if router emits faster than commons service', () => { + checkConfigurationObs('aa', '---xy', '----xy'); + }); + + it('should get 3 emissions of product configurations if both services emit fast', () => { + checkConfigurationObs('aa', 'xy', 'xxy'); + }); + + it('should get the maximum 4 emissions of product configurations if router pauses between emissions', () => { + checkConfigurationObs('a---a', 'xy', 'xy--xy'); + }); + + it('should only get the minimum needed 2 emissions of current groups if group service emits slowly', () => { + checkCurrentGroupObs('aa', '---uv', '----uv'); + }); + + it('should get 4 emissions of current groups if configurations service emits fast', () => { + checkCurrentGroupObs('a---a', '--uv', '--uv--uv'); + }); + + it('should get the maximum 8 emissions of current groups if router and config service emit slowly', () => { + checkCurrentGroupObs('a-----a', 'uv', 'uv----uv'); + }); + + it('check update configuration', () => { + spyOn(configuratorCommonsService, 'updateConfiguration').and.callThrough(); + isConfigurationLoadingObservable = cold('xy', { + x: true, + y: false, + }); + routerStateObservable = of(mockRouterState); + const fixture = TestBed.createComponent(ConfiguratorFormComponent); + const component = fixture.componentInstance; + component.updateConfiguration({ + changedAttribute: configRead.groups[0].attributes[0], + ownerKey: owner.key, + }); + + expect(configurationCommonsService.updateConfiguration).toHaveBeenCalled(); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/form/configurator-form.component.ts b/feature-libs/product-configurator/rulebased/components/form/configurator-form.component.ts new file mode 100644 index 00000000000..793c215ab18 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/form/configurator-form.component.ts @@ -0,0 +1,100 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { LanguageService } from '@spartacus/core'; +import { + ConfiguratorRouter, + ConfiguratorRouterExtractorService, +} from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { filter, switchMap, take } from 'rxjs/operators'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { ConfiguratorGroupsService } from '../../core/facade/configurator-groups.service'; +import { Configurator } from '../../core/model/configurator.model'; +import { ConfigFormUpdateEvent } from './configurator-form.event'; + +@Component({ + selector: 'cx-configurator-form', + templateUrl: './configurator-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorFormComponent implements OnInit { + configuration$: Observable< + Configurator.Configuration + > = this.configRouterExtractorService.extractRouterData().pipe( + filter( + (routerData) => + routerData.pageType === ConfiguratorRouter.PageType.CONFIGURATION + ), + switchMap((routerData) => { + return this.configuratorCommonsService.getOrCreateConfiguration( + routerData.owner + ); + }) + ); + currentGroup$: Observable< + Configurator.Group + > = this.configRouterExtractorService + .extractRouterData() + .pipe( + switchMap((routerData) => + this.configuratorGroupsService.getCurrentGroup(routerData.owner) + ) + ); + + activeLanguage$: Observable = this.languageService.getActive(); + + uiType = Configurator.UiType; + + constructor( + protected configuratorCommonsService: ConfiguratorCommonsService, + protected configuratorGroupsService: ConfiguratorGroupsService, + protected configRouterExtractorService: ConfiguratorRouterExtractorService, + protected languageService: LanguageService + ) {} + + ngOnInit(): void { + this.configRouterExtractorService + .extractRouterData() + .pipe(take(1)) + .subscribe((routingData) => { + //In case the 'forceReload' is set (means the page is launched from the cart), + //we need to initialise the cart configuration + if (routingData.forceReload) { + this.configuratorCommonsService.removeConfiguration( + routingData.owner + ); + } + + //In case of resolving issues, check if the configuration contains conflicts, + //if not, check if the configuration contains missing mandatory fields and show the group + if (routingData.resolveIssues) { + this.configuratorCommonsService + .hasConflicts(routingData.owner) + .pipe(take(1)) + .subscribe((hasConflicts) => { + if (hasConflicts) { + this.configuratorGroupsService.navigateToConflictSolver( + routingData.owner + ); + + //Only check for Incomplete group when there are no conflicts + } else { + this.configuratorGroupsService.navigateToFirstIncompleteGroup( + routingData.owner + ); + } + }); + } + }); + } + + updateConfiguration(event: ConfigFormUpdateEvent): void { + this.configuratorCommonsService.updateConfiguration( + event.ownerKey, + event.changedAttribute + ); + } + + isConflictGroupType(groupType: Configurator.GroupType): boolean { + return this.configuratorGroupsService.isConflictGroupType(groupType); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/form/configurator-form.event.ts b/feature-libs/product-configurator/rulebased/components/form/configurator-form.event.ts new file mode 100644 index 00000000000..fa768dca6b1 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/form/configurator-form.event.ts @@ -0,0 +1,5 @@ +import { Configurator } from '../../core/model/configurator.model'; +export class ConfigFormUpdateEvent { + ownerKey: string; + changedAttribute: Configurator.Attribute; +} diff --git a/feature-libs/product-configurator/rulebased/components/form/configurator-form.module.ts b/feature-libs/product-configurator/rulebased/components/form/configurator-form.module.ts new file mode 100644 index 00000000000..ba69ff6b608 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/form/configurator-form.module.ts @@ -0,0 +1,56 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { CmsConfig, I18nModule, provideDefaultConfig } from '@spartacus/core'; +import { ConfiguratorAttributeFooterModule } from '../attribute/footer/configurator-attribute-footer.module'; +import { ConfiguratorAttributeHeaderModule } from '../attribute/header/configurator-attribute-header.module'; +import { ConfiguratorAttributeCheckboxListModule } from '../attribute/types/checkbox-list/configurator-attribute-checkbox-list.module'; +import { ConfiguratorAttributeCheckboxModule } from '../attribute/types/checkbox/configurator-attribute-checkbox.module'; +import { ConfiguratorAttributeDropDownModule } from '../attribute/types/drop-down/configurator-attribute-drop-down.module'; +import { ConfiguratorAttributeInputFieldModule } from '../attribute/types/input-field/configurator-attribute-input-field.module'; +import { ConfiguratorAttributeMultiSelectionImageModule } from '../attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.module'; +import { ConfiguratorAttributeNumericInputFieldModule } from '../attribute/types/numeric-input-field/configurator-attribute-numeric-input-field.module'; +import { ConfiguratorAttributeRadioButtonModule } from '../attribute/types/radio-button/configurator-attribute-radio-button.module'; +import { ConfiguratorAttributeReadOnlyModule } from '../attribute/types/read-only/configurator-attribute-read-only.module'; +import { ConfiguratorAttributeSingleSelectionImageModule } from '../attribute/types/single-selection-image/configurator-attribute-single-selection-image.module'; +import { ConfiguratorConflictDescriptionModule } from '../conflict-description/configurator-conflict-description.module'; +import { ConfiguratorConflictSuggestionModule } from '../conflict-suggestion/configurator-conflict-suggestion.module'; +import { ConfiguratorFormComponent } from './configurator-form.component'; + +@NgModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + CommonModule, + I18nModule, + NgSelectModule, + ConfiguratorAttributeInputFieldModule, + ConfiguratorAttributeHeaderModule, + ConfiguratorAttributeFooterModule, + ConfiguratorAttributeNumericInputFieldModule, + ConfiguratorAttributeHeaderModule, + ConfiguratorAttributeRadioButtonModule, + ConfiguratorAttributeReadOnlyModule, + ConfiguratorAttributeSingleSelectionImageModule, + ConfiguratorAttributeCheckboxModule, + ConfiguratorAttributeCheckboxListModule, + ConfiguratorAttributeDropDownModule, + ConfiguratorAttributeMultiSelectionImageModule, + ConfiguratorConflictDescriptionModule, + ConfiguratorConflictSuggestionModule, + ], + providers: [ + provideDefaultConfig({ + cmsComponents: { + ConfiguratorForm: { + component: ConfiguratorFormComponent, + }, + }, + }), + ], + declarations: [ConfiguratorFormComponent], + exports: [ConfiguratorFormComponent], + entryComponents: [ConfiguratorFormComponent], +}) +export class ConfiguratorFormModule {} diff --git a/feature-libs/product-configurator/rulebased/components/form/index.ts b/feature-libs/product-configurator/rulebased/components/form/index.ts new file mode 100644 index 00000000000..0f67f19a924 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/form/index.ts @@ -0,0 +1,3 @@ +export * from './configurator-form.component'; +export * from './configurator-form.event'; +export * from './configurator-form.module'; diff --git a/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.html b/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.html new file mode 100644 index 00000000000..b3d7a6d8c33 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.html @@ -0,0 +1,72 @@ + + + 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 new file mode 100644 index 00000000000..0c573d03b69 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.spec.ts @@ -0,0 +1,664 @@ +import { Component, Directive, Input, Type } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Router, RouterState } from '@angular/router'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { I18nTestingModule, RoutingService } from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorTestUtilsService, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { HamburgerMenuService, ICON_TYPE } from '@spartacus/storefront'; +import { Observable, of } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { ConfiguratorGroupsService } from '../../core/facade/configurator-groups.service'; +import { Configurator } from '../../core/model/configurator.model'; +import { + ATTRIBUTE_1_CHECKBOX, + CONFIGURATOR_ROUTE, + GROUP_ID_1, + mockRouterState, + productConfiguration, + PRODUCT_CODE, +} from '../../shared/testing/configurator-test-data'; +import { ConfiguratorStorefrontUtilsService } from './../service/configurator-storefront-utils.service'; +import { ConfiguratorGroupMenuComponent } from './configurator-group-menu.component'; + +let mockGroupVisited = false; +const mockProductConfiguration: Configurator.Configuration = productConfiguration; + +class MockRoutingService { + getRouterState(): Observable { + return routerStateObservable; + } +} + +const simpleConfig: Configurator.Configuration = { + configId: mockProductConfiguration.configId, + groups: [ + { + id: GROUP_ID_1, + complete: true, + configurable: true, + consistent: true, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + attributes: [ + { + name: ATTRIBUTE_1_CHECKBOX, + uiType: Configurator.UiType.CHECKBOXLIST, + required: true, + incomplete: true, + }, + ], + subGroups: [], + }, + ], + interactionState: { + issueNavigationDone: false, + }, +}; + +const inconsistentConfig: Configurator.Configuration = { + configId: mockProductConfiguration.configId, + groups: mockProductConfiguration.groups, + flatGroups: mockProductConfiguration.flatGroups, + consistent: false, + complete: true, + interactionState: { + issueNavigationDone: false, + }, +}; + +const incompleteConfig: Configurator.Configuration = { + configId: mockProductConfiguration.configId, + groups: mockProductConfiguration.groups, + flatGroups: mockProductConfiguration.flatGroups, + consistent: true, + complete: false, + interactionState: { + issueNavigationDone: false, + }, +}; + +class MockRouter { + public events = of(''); +} + +const mockRouterStateIssueNavigation: any = { + state: { + params: { + entityKey: PRODUCT_CODE, + ownerType: CommonConfigurator.OwnerType.PRODUCT, + }, + queryParams: { resolveIssues: 'true' }, + semanticRoute: CONFIGURATOR_ROUTE, + }, +}; + +class MockConfiguratorGroupService { + setMenuParentGroup() {} + + getGroupStatus() { + return of(null); + } + + isGroupVisited() { + return groupVisitedObservable; + } + + findParentGroup() { + return null; + } + + navigateToGroup() {} + + getCurrentGroup(): Observable { + return of(mockProductConfiguration.groups[0]); + } + + getMenuParentGroup(): Observable { + return of(null); + } + + hasSubGroups(group: Configurator.Group): boolean { + return group.subGroups ? group.subGroups.length > 0 : false; + } + + getParentGroup(): Configurator.Group { + return null; + } + + isConflictGroupType() { + return isConflictGroupType; + } +} + +class MockConfiguratorCommonsService { + getConfiguration(): Observable { + return productConfigurationObservable; + } +} + +@Directive({ + selector: '[cxFocus]', +}) +export class MockFocusDirective { + @Input('cxFocus') protected config; +} + +@Component({ + selector: 'cx-icon', + template: '', +}) +class MockCxIconComponent { + @Input() type: ICON_TYPE; +} + +let component: ConfiguratorGroupMenuComponent; +let fixture: ComponentFixture; +let configuratorGroupsService: ConfiguratorGroupsService; +let hamburgerMenuService: HamburgerMenuService; +let htmlElem: HTMLElement; +let configuratorUtils: CommonConfiguratorUtilsService; +let routerStateObservable; +let groupVisitedObservable; +let productConfigurationObservable; +let isConflictGroupType; + +function initialize() { + groupVisitedObservable = of(mockGroupVisited); + fixture = TestBed.createComponent(ConfiguratorGroupMenuComponent); + component = fixture.componentInstance; + htmlElem = fixture.nativeElement; + fixture.detectChanges(); +} + +describe('ConfigurationGroupMenuComponent', () => { + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [I18nTestingModule, ReactiveFormsModule, NgSelectModule], + declarations: [ + ConfiguratorGroupMenuComponent, + MockCxIconComponent, + MockFocusDirective, + ], + providers: [ + HamburgerMenuService, + { + provide: Router, + useClass: MockRouter, + }, + { + provide: RoutingService, + useClass: MockRoutingService, + }, + + { + provide: ConfiguratorCommonsService, + useClass: MockConfiguratorCommonsService, + }, + { + provide: ConfiguratorGroupsService, + useClass: MockConfiguratorGroupService, + }, + { + provide: ConfiguratorStorefrontUtilsService, + useClass: ConfiguratorStorefrontUtilsService, + }, + ], + }); + }) + ); + + beforeEach(() => { + groupVisitedObservable = null; + + configuratorGroupsService = TestBed.inject( + ConfiguratorGroupsService as Type + ); + + hamburgerMenuService = TestBed.inject( + HamburgerMenuService as Type + ); + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + configuratorUtils.setOwnerKey(mockProductConfiguration.owner); + spyOn(configuratorGroupsService, 'navigateToGroup').and.stub(); + spyOn(configuratorGroupsService, 'setMenuParentGroup').and.stub(); + spyOn(configuratorGroupsService, 'isGroupVisited').and.callThrough(); + isConflictGroupType = false; + spyOn(configuratorGroupsService, 'isConflictGroupType').and.callThrough(); + spyOn(hamburgerMenuService, 'toggle').and.stub(); + }); + + it('should create component', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + initialize(); + expect(component).toBeDefined(); + }); + + it('should get product code as part of product configuration', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + initialize(); + component.configuration$.subscribe((data: Configurator.Configuration) => { + expect(data.productCode).toEqual(PRODUCT_CODE); + }); + }); + + it('should render 5 groups directly after init has been performed as groups are compiled without delay', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + initialize(); + expect(htmlElem.querySelectorAll('.cx-menu-item').length).toBe(5); + }); + + it('should render no groups if configuration is not consistent and issue navigation has not been done although required by the router', () => { + productConfigurationObservable = of(inconsistentConfig); + routerStateObservable = of(mockRouterStateIssueNavigation); + initialize(); + expect(htmlElem.querySelectorAll('.cx-menu-item').length).toBe(0); + }); + + it('should render no groups if configuration is not complete and issue navigation has not been done although required by the router', () => { + productConfigurationObservable = of(incompleteConfig); + routerStateObservable = of(mockRouterStateIssueNavigation); + initialize(); + expect(htmlElem.querySelectorAll('.cx-menu-item').length).toBe(0); + }); + + it('should render all groups if configuration is not consistent and issue navigation has not been done but also not required by the router', () => { + productConfigurationObservable = of(inconsistentConfig); + routerStateObservable = of(mockRouterState); + initialize(); + expect(htmlElem.querySelectorAll('.cx-menu-item').length).toBe(5); + }); + + it('should render all groups if configuration is not complete and issue navigation has not been done but also not required by the router', () => { + productConfigurationObservable = of(incompleteConfig); + routerStateObservable = of(mockRouterState); + initialize(); + expect(htmlElem.querySelectorAll('.cx-menu-item').length).toBe(5); + }); + + it('should render groups if configuration is not consistent but issues have been checked', () => { + productConfigurationObservable = of(incompleteConfig); + routerStateObservable = of(mockRouterState); + initialize(); + expect(htmlElem.querySelectorAll('.cx-menu-item').length).toBe(5); + }); + + it('should return 5 groups after groups have been compiled', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + initialize(); + component.displayedGroups$.pipe(take(1)).subscribe((groups) => { + expect(groups.length).toBe(5); + }); + }); + + it('should return 0 groups if menu parent group is first group', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + spyOn(configuratorGroupsService, 'getMenuParentGroup').and.returnValue( + of(mockProductConfiguration.groups[0]) + ); + initialize(); + + component.displayedGroups$.pipe(take(1)).subscribe((groups) => { + expect(groups.length).toBe(0); + }); + }); + + it('should set current group in case of clicking on a group', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + initialize(); + + component.click(mockProductConfiguration.groups[1]); + + expect(configuratorGroupsService.navigateToGroup).toHaveBeenCalled(); + expect(hamburgerMenuService.toggle).toHaveBeenCalled(); + }); + + it('should set current group in case of hitting Enter on a group', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + initialize(); + + const event = new KeyboardEvent('keypress', { + code: 'Enter', + }); + component.clickOnEnter(event, mockProductConfiguration.groups[1]); + + expect(configuratorGroupsService.navigateToGroup).toHaveBeenCalled(); + expect(hamburgerMenuService.toggle).toHaveBeenCalled(); + }); + + it('should do nothing hitting key other than enter on a group', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + initialize(); + + const event = new KeyboardEvent('keypress', { + code: 'Space', + }); + component.clickOnEnter(event, mockProductConfiguration.groups[1]); + + expect(configuratorGroupsService.navigateToGroup).toHaveBeenCalledTimes(0); + expect(hamburgerMenuService.toggle).toHaveBeenCalledTimes(0); + }); + + it('should condense groups', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + initialize(); + expect( + component.condenseGroups(mockProductConfiguration.groups)[2].id + ).toBe(mockProductConfiguration.groups[2].subGroups[0].id); + }); + + it('should get correct parent group for condensed groups', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + initialize(); + //Condensed case + component + .getCondensedParentGroup(mockProductConfiguration.groups[2]) + .pipe(take(1)) + .subscribe((group) => { + expect(group).toBe(null); + }); + + //Non condensed case + component + .getCondensedParentGroup(mockProductConfiguration.groups[0]) + .pipe(take(1)) + .subscribe((group) => { + expect(group).toBe(mockProductConfiguration.groups[0]); + }); + }); + + it('should navigate up', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + spyOn(configuratorGroupsService, 'getParentGroup').and.returnValue( + mockProductConfiguration.groups[0] + ); + initialize(); + component.navigateUp(); + expect(configuratorGroupsService.getParentGroup).toHaveBeenCalled(); + expect(configuratorGroupsService.setMenuParentGroup).toHaveBeenCalled(); + }); + + it('should navigate up, parent group null', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + spyOn(configuratorGroupsService, 'getParentGroup').and.callThrough(); + initialize(); + component.navigateUp(); + expect(configuratorGroupsService.getParentGroup).toHaveBeenCalled(); + expect(configuratorGroupsService.setMenuParentGroup).toHaveBeenCalled(); + }); + + it('should navigate up on hitting enter', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + spyOn(configuratorGroupsService, 'getParentGroup').and.returnValue( + mockProductConfiguration.groups[0] + ); + initialize(); + const event = new KeyboardEvent('keypress', { + code: 'Enter', + }); + component.navigateUpOnEnter(event); + expect(configuratorGroupsService.getParentGroup).toHaveBeenCalled(); + expect(configuratorGroupsService.setMenuParentGroup).toHaveBeenCalled(); + }); + + it('should not navigate up on hitting a key other than enter', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + spyOn(configuratorGroupsService, 'getParentGroup').and.returnValue( + mockProductConfiguration.groups[0] + ); + initialize(); + const event = new KeyboardEvent('keypress', { + code: 'Space', + }); + component.navigateUpOnEnter(event); + expect(configuratorGroupsService.getParentGroup).toHaveBeenCalledTimes(0); + expect(configuratorGroupsService.setMenuParentGroup).toHaveBeenCalledTimes( + 0 + ); + }); + + it('should call correct methods for groups with and without subgroups', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + initialize(); + //Set group + component.click(mockProductConfiguration.groups[2].subGroups[0]); + expect(configuratorGroupsService.navigateToGroup).toHaveBeenCalled(); + expect(hamburgerMenuService.toggle).toHaveBeenCalled(); + + //Display subgroups + component.click(mockProductConfiguration.groups[2]); + expect(configuratorGroupsService.setMenuParentGroup).toHaveBeenCalled(); + }); + + it('should return number of conflicts only for conflict header group', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + initialize(); + const groupWithConflicts = { + groupType: Configurator.GroupType.CONFLICT_HEADER_GROUP, + subGroups: [ + { groupType: Configurator.GroupType.CONFLICT_GROUP }, + { groupType: Configurator.GroupType.CONFLICT_GROUP }, + ], + }; + expect(component.getConflictNumber(groupWithConflicts)).toBe('(2)'); + const attributeGroup = { + groupType: Configurator.GroupType.SUB_ITEM_GROUP, + subGroups: [ + { groupType: Configurator.GroupType.ATTRIBUTE_GROUP }, + { groupType: Configurator.GroupType.ATTRIBUTE_GROUP }, + ], + }; + expect(component.getConflictNumber(attributeGroup)).toBe(''); + }); + + describe('isGroupVisited', () => { + it('should call status method if group has been visited', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + mockGroupVisited = true; + initialize(); + component + .isGroupVisited( + mockProductConfiguration.groups[0], + mockProductConfiguration + ) + .pipe(take(1)) + .subscribe(); + + expect(configuratorGroupsService.isGroupVisited).toHaveBeenCalled(); + expect(configuratorGroupsService.isConflictGroupType).toHaveBeenCalled(); + }); + + it('should return true if visited and not conflict group', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + mockGroupVisited = true; + initialize(); + component + .isGroupVisited( + mockProductConfiguration.groups[0], + mockProductConfiguration + ) + .pipe(take(1)) + .subscribe((visited) => expect(visited).toBeTrue()); + }); + + it('should return false if visited and if it is a conflict group', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + mockGroupVisited = true; + isConflictGroupType = true; + initialize(); + component + .isGroupVisited( + mockProductConfiguration.groups[0], + mockProductConfiguration + ) + .pipe(take(1)) + .subscribe((visited) => expect(visited).toBeFalse()); + }); + + it('should return false if not visited and not conflict group', () => { + productConfigurationObservable = of(mockProductConfiguration); + routerStateObservable = of(mockRouterState); + mockGroupVisited = false; + isConflictGroupType = false; + initialize(); + component + .isGroupVisited( + mockProductConfiguration.groups[0], + mockProductConfiguration + ) + .pipe(take(1)) + .subscribe((visited) => expect(visited).toBeFalse()); + }); + }); + + describe('verify whether the corresponding group status class stands at cx-menu-item element', () => { + it("should contain 'WARNING' class despite the group has not been visited", () => { + simpleConfig.consistent = false; + simpleConfig.groups[0].consistent = false; + productConfigurationObservable = of(simpleConfig); + routerStateObservable = of(mockRouterState); + mockGroupVisited = false; + isConflictGroupType = true; + initialize(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'li.cx-menu-item.WARNING' + ); + }); + + it("should contain 'WARNING' class because the group has been visited and has some conflicts", () => { + simpleConfig.consistent = false; + simpleConfig.groups[0].consistent = false; + productConfigurationObservable = of(simpleConfig); + routerStateObservable = of(mockRouterState); + mockGroupVisited = true; + isConflictGroupType = true; + initialize(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'li.cx-menu-item.WARNING' + ); + }); + + it("should not contain 'COMPLETE' class despite the group is complete but it has not been visited", () => { + simpleConfig.complete = true; + simpleConfig.groups[0].complete = true; + simpleConfig.groups[0].consistent = true; + productConfigurationObservable = of(simpleConfig); + routerStateObservable = of(mockRouterState); + mockGroupVisited = false; + isConflictGroupType = false; + initialize(); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + 'li.cx-menu-item.COMPLETE' + ); + }); + + it("should not contain 'COMPLETE' class despite the group is complete and visited but it has conflicts", () => { + simpleConfig.complete = true; + simpleConfig.groups[0].complete = true; + simpleConfig.groups[0].consistent = false; + productConfigurationObservable = of(simpleConfig); + routerStateObservable = of(mockRouterState); + mockGroupVisited = true; + isConflictGroupType = false; + initialize(); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + 'li.cx-menu-item.COMPLETE' + ); + }); + + it("should contain 'COMPLETE' class because the group is complete and has been visited", () => { + simpleConfig.complete = true; + simpleConfig.groups[0].complete = true; + simpleConfig.groups[0].consistent = true; + productConfigurationObservable = of(simpleConfig); + routerStateObservable = of(mockRouterState); + mockGroupVisited = true; + isConflictGroupType = false; + initialize(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'li.cx-menu-item.COMPLETE' + ); + }); + + it("should not contain 'ERROR' class despite the group is incomplete but it has not been visited", () => { + simpleConfig.complete = false; + simpleConfig.groups[0].complete = false; + productConfigurationObservable = of(simpleConfig); + routerStateObservable = of(mockRouterState); + mockGroupVisited = false; + isConflictGroupType = false; + initialize(); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + 'li.cx-menu-item.ERROR' + ); + }); + + it("should contain 'ERROR' class because the group is incomplete and has been visited", () => { + simpleConfig.complete = false; + simpleConfig.groups[0].complete = false; + productConfigurationObservable = of(simpleConfig); + routerStateObservable = of(mockRouterState); + mockGroupVisited = true; + isConflictGroupType = false; + initialize(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'li.cx-menu-item.ERROR' + ); + }); + + it("should contain 'DISABLED' class despite the group is empty", () => { + simpleConfig.complete = true; + simpleConfig.groups[0].configurable = false; + productConfigurationObservable = of(simpleConfig); + routerStateObservable = of(mockRouterState); + mockGroupVisited = false; + isConflictGroupType = false; + initialize(); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'li.cx-menu-item.DISABLED' + ); + }); + }); +}); 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 new file mode 100644 index 00000000000..7abc70ae68c --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.component.ts @@ -0,0 +1,249 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + ConfiguratorRouter, + ConfiguratorRouterExtractorService, +} from '@spartacus/product-configurator/common'; +import { HamburgerMenuService, ICON_TYPE } from '@spartacus/storefront'; +import { Observable, of } from 'rxjs'; +import { filter, map, switchMap, take } from 'rxjs/operators'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { ConfiguratorGroupsService } from '../../core/facade/configurator-groups.service'; +import { Configurator } from '../../core/model/configurator.model'; +import { ConfiguratorStorefrontUtilsService } from '../service/configurator-storefront-utils.service'; + +@Component({ + selector: 'cx-configurator-group-menu', + templateUrl: './configurator-group-menu.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorGroupMenuComponent { + routerData$: Observable< + ConfiguratorRouter.Data + > = this.configRouterExtractorService.extractRouterData(); + + configuration$: Observable< + Configurator.Configuration + > = this.routerData$.pipe( + switchMap((routerData) => + this.configCommonsService + .getConfiguration(routerData.owner) + .pipe( + map((configuration) => ({ routerData, configuration })), + //We need to ensure that the navigation to conflict groups or + //groups with mandatory attributes already has taken place, as this happens + //in an onInit of another component. + //otherwise we risk that this component is completely initialized too early, + //in dev mode resulting in ExpressionChangedAfterItHasBeenCheckedError + filter( + (cont) => + (cont.configuration.complete && cont.configuration.consistent) || + cont.configuration.interactionState.issueNavigationDone || + !cont.routerData.resolveIssues + ) + ) + + .pipe(map((cont) => cont.configuration)) + ) + ); + + currentGroup$: Observable = this.routerData$.pipe( + switchMap((routerData) => + this.configuratorGroupsService.getCurrentGroup(routerData.owner) + ) + ); + + displayedParentGroup$: Observable< + Configurator.Group + > = this.configuration$.pipe( + switchMap((configuration) => + this.configuratorGroupsService.getMenuParentGroup(configuration.owner) + ), + switchMap((parentGroup) => this.getCondensedParentGroup(parentGroup)) + ); + + displayedGroups$: Observable< + Configurator.Group[] + > = this.displayedParentGroup$.pipe( + switchMap((parentGroup) => { + return this.configuration$.pipe( + map((configuration) => { + if (parentGroup) { + return this.condenseGroups(parentGroup.subGroups); + } else { + return this.condenseGroups(configuration.groups); + } + }) + ); + }) + ); + + iconTypes = ICON_TYPE; + + constructor( + protected configCommonsService: ConfiguratorCommonsService, + protected configuratorGroupsService: ConfiguratorGroupsService, + protected hamburgerMenuService: HamburgerMenuService, + protected configRouterExtractorService: ConfiguratorRouterExtractorService, + protected configUtils: ConfiguratorStorefrontUtilsService + ) {} + + /** + * Fired on key board events, checks for 'enter' and delegates to click. + * + * @param {KeyboardEvent} event - Keyboard event + * @param {Configurator.Group} group - Entered group + */ + clickOnEnter(event: KeyboardEvent, group: Configurator.Group): void { + if (event.code === 'Enter') { + this.click(group); + } + } + + click(group: Configurator.Group): void { + this.configuration$.pipe(take(1)).subscribe((configuration) => { + if (!this.configuratorGroupsService.hasSubGroups(group)) { + this.configuratorGroupsService.navigateToGroup(configuration, group.id); + this.hamburgerMenuService.toggle(true); + + this.configUtils.scrollToConfigurationElement( + '.VariantConfigurationTemplate' + ); + } else { + this.configuratorGroupsService.setMenuParentGroup( + configuration.owner, + group.id + ); + } + }); + } + + /** + * Fired on key board events, checks for 'enter' and delegates to navigateUp. + * + * @param {KeyboardEvent} event - Keyboard event + */ + navigateUpOnEnter(event: KeyboardEvent): void { + if (event.code === 'Enter') { + this.navigateUp(); + } + } + + navigateUp(): void { + this.displayedParentGroup$ + .pipe(take(1)) + .subscribe((displayedParentGroup) => { + const parentGroup$ = this.getParentGroup(displayedParentGroup); + this.configuration$.pipe(take(1)).subscribe((configuration) => { + parentGroup$ + .pipe(take(1)) + .subscribe((parentGroup) => + this.configuratorGroupsService.setMenuParentGroup( + configuration.owner, + parentGroup ? parentGroup.id : null + ) + ); + }); + }); + } + + /** + * Retrieves the number of conflicts for the current group. + * + * @param {Configurator.Group} group - Current group + * @return {string} - number of conflicts + */ + getConflictNumber(group: Configurator.Group): string { + if (group.groupType === Configurator.GroupType.CONFLICT_HEADER_GROUP) { + return '(' + group.subGroups.length + ')'; + } + return ''; + } + + /** + * Verifies whether the current group has a subgroups. + * + * @param {Configurator.Group} group - Current group + * @return {boolean} - Returns 'true' if the current group has a subgroups, otherwise 'false'. + */ + hasSubGroups(group: Configurator.Group): boolean { + return this.configuratorGroupsService.hasSubGroups(group); + } + + protected getParentGroup( + group: Configurator.Group + ): Observable { + return this.configuration$.pipe( + map((configuration) => + this.configuratorGroupsService.getParentGroup( + configuration.groups, + group + ) + ) + ); + } + + getCondensedParentGroup( + parentGroup: Configurator.Group + ): Observable { + if ( + parentGroup && + parentGroup.subGroups && + parentGroup.subGroups.length === 1 && + parentGroup.groupType !== Configurator.GroupType.CONFLICT_HEADER_GROUP + ) { + return this.getParentGroup(parentGroup).pipe( + switchMap((group) => this.getCondensedParentGroup(group)) + ); + } else { + return of(parentGroup); + } + } + + condenseGroups(groups: Configurator.Group[]): Configurator.Group[] { + return groups.flatMap((group) => { + if ( + group.subGroups.length === 1 && + group.groupType !== Configurator.GroupType.CONFLICT_HEADER_GROUP + ) { + return this.condenseGroups(group.subGroups); + } else { + return group; + } + }); + } + + /** + * 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 + */ + isGroupVisited( + group: Configurator.Group, + configuration: Configurator.Configuration + ): Observable { + return this.configuratorGroupsService + .isGroupVisited(configuration.owner, group.id) + .pipe( + switchMap((isVisited) => { + if (isVisited && !this.isConflictGroupType(group.groupType)) { + return of(true); + } else { + return of(false); + } + }), + take(1) + ); + } + + /** + * 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'. + */ + isConflictGroupType(groupType: Configurator.GroupType): boolean { + return this.configuratorGroupsService.isConflictGroupType(groupType); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.module.ts b/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.module.ts new file mode 100644 index 00000000000..43471b4d499 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/group-menu/configurator-group-menu.module.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { CmsConfig, I18nModule, provideDefaultConfig } from '@spartacus/core'; +import { IconModule, KeyboardFocusModule } from '@spartacus/storefront'; +import { ConfiguratorGroupMenuComponent } from './configurator-group-menu.component'; + +@NgModule({ + imports: [CommonModule, I18nModule, IconModule, KeyboardFocusModule], + providers: [ + provideDefaultConfig({ + cmsComponents: { + ConfiguratorMenu: { + component: ConfiguratorGroupMenuComponent, + }, + }, + }), + ], + declarations: [ConfiguratorGroupMenuComponent], + exports: [ConfiguratorGroupMenuComponent], + entryComponents: [ConfiguratorGroupMenuComponent], +}) +export class ConfiguratorGroupMenuModule {} diff --git a/feature-libs/product-configurator/rulebased/components/group-menu/index.ts b/feature-libs/product-configurator/rulebased/components/group-menu/index.ts new file mode 100644 index 00000000000..32846247985 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/group-menu/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-group-menu.component'; +export * from './configurator-group-menu.module'; diff --git a/feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.component.html b/feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.component.html new file mode 100644 index 00000000000..73df54f7bea --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.component.html @@ -0,0 +1,3 @@ + + {{ group.description }} + diff --git a/feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.component.spec.ts b/feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.component.spec.ts new file mode 100644 index 00000000000..92928c8cfb6 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.component.spec.ts @@ -0,0 +1,117 @@ +import { Type } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Router, RouterState } from '@angular/router'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { I18nTestingModule, RoutingService } from '@spartacus/core'; +import { CommonConfiguratorUtilsService } from '@spartacus/product-configurator/common'; +import { IconLoaderService } from '@spartacus/storefront'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { ConfiguratorGroupsService } from '../../core/facade/configurator-groups.service'; +import { Configurator } from '../../core/model/configurator.model'; +import * as ConfigurationTestData from '../../shared/testing/configurator-test-data'; +import { ConfiguratorGroupTitleComponent } from './configurator-group-title.component'; + +const config: Configurator.Configuration = + ConfigurationTestData.productConfiguration; + +let routerStateObservable = null; +const group = { id: '1-CPQ_LAPTOP.1' }; +class MockRoutingService { + getRouterState(): Observable { + return routerStateObservable; + } +} + +class MockRouter { + public events = of(''); +} + +class MockConfiguratorGroupService { + navigateToGroup() {} + getCurrentGroup(): Observable { + return of(group); + } +} + +class MockConfiguratorCommonsService { + getConfiguration(): Observable { + return of(config); + } + hasConfiguration(): Observable { + return of(false); + } + readConfiguration(): Observable { + return of(config); + } +} + +export class MockIconFontLoaderService { + getFlipDirection(): void {} +} + +describe('ConfigurationGroupTitleComponent', () => { + let component: ConfiguratorGroupTitleComponent; + let fixture: ComponentFixture; + let configuratorGroupsService: ConfiguratorGroupsService; + let configuratorUtils: CommonConfiguratorUtilsService; + + beforeEach( + waitForAsync(() => { + routerStateObservable = of(ConfigurationTestData.mockRouterState); + TestBed.configureTestingModule({ + imports: [I18nTestingModule, ReactiveFormsModule, NgSelectModule], + declarations: [ConfiguratorGroupTitleComponent], + providers: [ + { + provide: Router, + useClass: MockRouter, + }, + { + provide: RoutingService, + useClass: MockRoutingService, + }, + + { + provide: ConfiguratorCommonsService, + useClass: MockConfiguratorCommonsService, + }, + { + provide: ConfiguratorGroupsService, + useClass: MockConfiguratorGroupService, + }, + { provide: IconLoaderService, useClass: MockIconFontLoaderService }, + ], + }); + }) + ); + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorGroupTitleComponent); + component = fixture.componentInstance; + + configuratorGroupsService = TestBed.inject(ConfiguratorGroupsService); + + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + configuratorUtils.setOwnerKey(config.owner); + spyOn(configuratorGroupsService, 'navigateToGroup').and.stub(); + }); + + it('should create component', () => { + expect(component).toBeDefined(); + }); + + it('should get product code as part of product configuration', () => { + component.configuration$.subscribe((data: Configurator.Configuration) => { + expect(data.productCode).toEqual(config.productCode); + }); + }); + + it('should get group id as part of group', () => { + component.displayedGroup$.subscribe((data: Configurator.Group) => { + expect(data.id).toEqual(group.id); + }); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.component.ts b/feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.component.ts new file mode 100644 index 00000000000..270cd2fd22f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.component.ts @@ -0,0 +1,42 @@ +import { Component } from '@angular/core'; +import { ConfiguratorRouterExtractorService } from '@spartacus/product-configurator/common'; +import { ICON_TYPE } from '@spartacus/storefront'; +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { ConfiguratorGroupsService } from '../../core/facade/configurator-groups.service'; +import { Configurator } from '../../core/model/configurator.model'; + +@Component({ + selector: 'cx-configurator-group-title', + templateUrl: './configurator-group-title.component.html', +}) +export class ConfiguratorGroupTitleComponent { + configuration$: Observable< + Configurator.Configuration + > = this.configRouterExtractorService + .extractRouterData() + .pipe( + switchMap((routerData) => + this.configuratorCommonsService.getConfiguration(routerData.owner) + ) + ); + + displayedGroup$: Observable< + Configurator.Group + > = this.configRouterExtractorService + .extractRouterData() + .pipe( + switchMap((routerData) => + this.configuratorGroupsService.getCurrentGroup(routerData.owner) + ) + ); + + iconTypes = ICON_TYPE; + + constructor( + protected configuratorCommonsService: ConfiguratorCommonsService, + protected configuratorGroupsService: ConfiguratorGroupsService, + protected configRouterExtractorService: ConfiguratorRouterExtractorService + ) {} +} diff --git a/feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.module.ts b/feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.module.ts new file mode 100644 index 00000000000..c31bc66ca22 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/group-title/configurator-group-title.module.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { CmsConfig, provideDefaultConfig } from '@spartacus/core'; +import { ConfiguratorGroupTitleComponent } from './configurator-group-title.component'; + +@NgModule({ + imports: [CommonModule], + providers: [ + provideDefaultConfig({ + cmsComponents: { + ConfiguratorGroupTitle: { + component: ConfiguratorGroupTitleComponent, + }, + }, + }), + ], + declarations: [ConfiguratorGroupTitleComponent], + exports: [ConfiguratorGroupTitleComponent], + entryComponents: [ConfiguratorGroupTitleComponent], +}) +export class ConfiguratorGroupTitleModule {} diff --git a/feature-libs/product-configurator/rulebased/components/group-title/index.ts b/feature-libs/product-configurator/rulebased/components/group-title/index.ts new file mode 100644 index 00000000000..721b7f95732 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/group-title/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-group-title.component'; +export * from './configurator-group-title.module'; diff --git a/feature-libs/product-configurator/rulebased/components/index.ts b/feature-libs/product-configurator/rulebased/components/index.ts new file mode 100644 index 00000000000..e29dc69c752 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/index.ts @@ -0,0 +1,18 @@ +export * from './add-to-cart-button/index'; +export * from './attribute/index'; +export * from './config/index'; +export * from './conflict-description/index'; +export * from './conflict-suggestion/index'; +export * from './form/index'; +export * from './group-menu/index'; +export * from './group-title/index'; +export * from './overview-attribute/index'; +export * from './overview-form/index'; +export * from './overview-notification-banner/index'; +export * from './previous-next-buttons/index'; +export * from './price-summary/index'; +export * from './product-title/index'; +export * from './rulebased-configurator-components.module'; +export * from './service/index'; +export * from './tab-bar/index'; +export * from './update-message/index'; diff --git a/feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.component.html b/feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.component.html new file mode 100644 index 00000000000..5b88445bfd9 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.component.html @@ -0,0 +1,4 @@ +
{{ attributeOverview.value }}
+
+ {{ attributeOverview.attribute }} +
diff --git a/feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.component.spec.ts b/feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.component.spec.ts new file mode 100644 index 00000000000..ba347e8c814 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.component.spec.ts @@ -0,0 +1,40 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { ConfiguratorOverviewAttributeComponent } from './configurator-overview-attribute.component'; + +describe('ConfigurationOverviewAttributeComponent', () => { + let component: ConfiguratorOverviewAttributeComponent; + let fixture: ComponentFixture; + let htmlElem: HTMLElement; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, NgSelectModule], + declarations: [ConfiguratorOverviewAttributeComponent], + providers: [], + }); + }) + ); + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorOverviewAttributeComponent); + component = fixture.componentInstance; + htmlElem = fixture.nativeElement; + component.attributeOverview = { + attribute: 'Test Attribute Name', + value: 'Test Attribute Value', + }; + fixture.detectChanges(); + }); + + it('should create component', () => { + expect(component).toBeDefined(); + }); + + it('should show attribute value', () => { + expect(htmlElem.querySelectorAll('.cx-attribute-value').length).toBe(1); + + expect(htmlElem.querySelectorAll('.cx-attribute-label').length).toBe(1); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.component.ts b/feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.component.ts new file mode 100644 index 00000000000..6857da34db4 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.component.ts @@ -0,0 +1,10 @@ +import { Component, Input } from '@angular/core'; +import { Configurator } from '../../core/model/configurator.model'; + +@Component({ + selector: 'cx-configurator-overview-attribute', + templateUrl: './configurator-overview-attribute.component.html', +}) +export class ConfiguratorOverviewAttributeComponent { + @Input() attributeOverview: Configurator.AttributeOverview; +} diff --git a/feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.module.ts b/feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.module.ts new file mode 100644 index 00000000000..2ab843c322b --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-attribute/configurator-overview-attribute.module.ts @@ -0,0 +1,11 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { ConfiguratorOverviewAttributeComponent } from './configurator-overview-attribute.component'; + +@NgModule({ + imports: [CommonModule], + declarations: [ConfiguratorOverviewAttributeComponent], + exports: [ConfiguratorOverviewAttributeComponent], + entryComponents: [ConfiguratorOverviewAttributeComponent], +}) +export class ConfiguratorOverviewAttributeModule {} diff --git a/feature-libs/product-configurator/rulebased/components/overview-attribute/index.ts b/feature-libs/product-configurator/rulebased/components/overview-attribute/index.ts new file mode 100644 index 00000000000..541c459d4c4 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-attribute/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-overview-attribute.component'; +export * from './configurator-overview-attribute.module'; diff --git a/feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.component.html b/feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.component.html new file mode 100644 index 00000000000..fec9ced8029 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.component.html @@ -0,0 +1,27 @@ + + + +
+

+ {{ group.groupDescription }} +

+
+ +
+
+
+
+
+ + +
+ +

{{ 'configurator.overviewForm.noAttributeHeader' | cxTranslate }}

+

{{ 'configurator.overviewForm.noAttributeText' | cxTranslate }}

+
+
diff --git a/feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.component.spec.ts b/feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.component.spec.ts new file mode 100644 index 00000000000..3554610bc32 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.component.spec.ts @@ -0,0 +1,268 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterState } from '@angular/router'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { I18nTestingModule, RoutingService } from '@spartacus/core'; +import { + CommonConfigurator, + ConfiguratorRouter, + ConfiguratorRouterExtractorService, +} from '@spartacus/product-configurator/common'; +import { cold } from 'jasmine-marbles'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { Configurator } from '../../core/model/configurator.model'; +import * as ConfigurationTestData from '../../shared/testing/configurator-test-data'; +import { ConfiguratorOverviewAttributeComponent } from '../overview-attribute/configurator-overview-attribute.component'; +import { ConfiguratorOverviewFormComponent } from './configurator-overview-form.component'; + +const owner: CommonConfigurator.Owner = + ConfigurationTestData.productConfiguration.owner; +const mockRouterState: any = ConfigurationTestData.mockRouterState; +const configId = '1234-56-7890'; + +const configCreate: Configurator.Configuration = { + configId: configId, + owner: owner, + overview: ConfigurationTestData.productConfiguration.overview, +}; +const configCreate2: Configurator.Configuration = { + configId: '1234-11111', + owner: owner, + overview: ConfigurationTestData.productConfiguration.overview, +}; +const configInitial: Configurator.Configuration = { + configId: configId, + owner: owner, + overview: { + groups: [], + }, +}; + +let routerStateObservable; +let configurationObservable; +let overviewObservable; +let defaultConfigObservable; +let defaultRouterStateObservable; +let defaultRouterDataObservable; +let component: ConfiguratorOverviewFormComponent; +let fixture: ComponentFixture; +let htmlElem: HTMLElement; + +class MockRoutingService { + getRouterState(): Observable { + const obs: Observable = routerStateObservable + ? routerStateObservable + : defaultRouterStateObservable; + return obs; + } +} + +class MockRouterExtractorService { + extractRouterData(): Observable { + return of(defaultRouterDataObservable); + } +} + +class MockConfiguratorCommonsService { + getOrCreateConfiguration( + productCode: string + ): Observable { + configCreate.productCode = productCode; + const obs: Observable = configurationObservable + ? configurationObservable + : defaultConfigObservable; + return obs; + } + getConfigurationWithOverview( + configuration: Configurator.Configuration + ): Observable { + const obs: Observable = overviewObservable + ? overviewObservable + : of(configuration); + return obs; + } + removeConfiguration(): void {} +} + +function initialize() { + fixture = TestBed.createComponent(ConfiguratorOverviewFormComponent); + htmlElem = fixture.nativeElement; + component = fixture.componentInstance; + fixture.detectChanges(); +} + +function checkConfigurationOverviewObs( + routerMarbels: string, + configurationMarbels: string, + overviewMarbels: string, + expectedMarbels: string +) { + routerStateObservable = cold(routerMarbels, { + a: mockRouterState, + }); + configurationObservable = cold(configurationMarbels, { + x: configCreate, + y: configCreate2, + }); + overviewObservable = cold(overviewMarbels, { + u: configCreate, + v: configCreate2, + }); + initialize(); + expect(component.configuration$).toBeObservable( + cold(expectedMarbels, { u: configCreate, v: configCreate2 }) + ); +} + +describe('ConfigurationOverviewFormComponent', () => { + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [I18nTestingModule, ReactiveFormsModule, NgSelectModule], + declarations: [ + ConfiguratorOverviewFormComponent, + ConfiguratorOverviewAttributeComponent, + ], + providers: [ + { + provide: RoutingService, + useClass: MockRoutingService, + }, + { + provide: ConfiguratorCommonsService, + useClass: MockConfiguratorCommonsService, + }, + ], + }).compileComponents(); + }) + ); + beforeEach(() => { + routerStateObservable = null; + configurationObservable = null; + overviewObservable = null; + defaultRouterStateObservable = of(mockRouterState); + defaultConfigObservable = of(configCreate2); + }); + + it('should create component', () => { + initialize(); + expect(component).toBeDefined(); + }); + + it('should display configuration overview', () => { + defaultConfigObservable = of(configCreate2); + initialize(); + + expect(htmlElem.querySelectorAll('.cx-group').length).toBe(2); + + expect(htmlElem.querySelectorAll('.cx-attribute-value-pair').length).toBe( + 3 + ); + }); + + it('should display no result text in case of empty configuration', () => { + defaultConfigObservable = of(configInitial); + initialize(); + + expect(htmlElem.querySelectorAll('.cx-group').length).toBe(0); + + expect(htmlElem.querySelectorAll('.cx-attribute-value-pair').length).toBe( + 0 + ); + + expect( + htmlElem.querySelectorAll('.cx-no-attribute-value-pairs').length + ).toBe(1); + }); + + it('should only get the minimum needed 2 emissions of overview if overview emits slowly', () => { + checkConfigurationOverviewObs('aa', '---xy', '---uv', '--------uv'); + }); + + it('should get 4 emissions of overview if configurations service emits fast', () => { + checkConfigurationOverviewObs('a---a', 'xy', '--uv', '---uv--uv'); + }); + + it('should know if a configuration OV has attributes', () => { + initialize(); + expect(component.hasAttributes(configCreate)).toBe(true); + }); + + it('should detect that a configuration w/o groups has no attributes', () => { + initialize(); + const configWOOverviewGroups: Configurator.Configuration = { + configId: configId, + overview: {}, + }; + expect(component.hasAttributes(configWOOverviewGroups)).toBe(false); + }); + + it('should detect that a configuration w/o groups that carry attributes does not provide OV attributes', () => { + initialize(); + const configWOOverviewAttributes: Configurator.Configuration = { + configId: configId, + overview: { groups: [{ id: 'GROUP1' }] }, + }; + expect(component.hasAttributes(configWOOverviewAttributes)).toBe(false); + }); +}); + +describe('ConfigurationOverviewFormComponent with forceReload', () => { + let configuratorCommonsServiceMock: ConfiguratorCommonsService; + const theOwner = { + id: '1', + type: CommonConfigurator.OwnerType.CART_ENTRY, + configuratorType: 'cpqconfigurator', + }; + beforeEach( + waitForAsync(() => { + const bed = TestBed.configureTestingModule({ + imports: [I18nTestingModule, ReactiveFormsModule, NgSelectModule], + declarations: [ + ConfiguratorOverviewFormComponent, + ConfiguratorOverviewAttributeComponent, + ], + providers: [ + { + provide: RoutingService, + useClass: MockRoutingService, + }, + { + provide: ConfiguratorCommonsService, + useClass: MockConfiguratorCommonsService, + }, + { + provide: ConfiguratorRouterExtractorService, + useClass: MockRouterExtractorService, + }, + ], + }); + configuratorCommonsServiceMock = bed.inject(ConfiguratorCommonsService); + bed.compileComponents(); + }) + ); + beforeEach(() => { + routerStateObservable = null; + configurationObservable = null; + overviewObservable = null; + defaultRouterStateObservable = of(mockRouterState); + defaultConfigObservable = of(configCreate2); + defaultRouterDataObservable = { + forceReload: true, + owner: theOwner, + }; + }); + + it('should create component and call removeConfiguration', () => { + spyOn( + configuratorCommonsServiceMock, + 'removeConfiguration' + ).and.callThrough(); + initialize(); + expect(component).toBeDefined(); + expect( + configuratorCommonsServiceMock.removeConfiguration + ).toHaveBeenCalledWith(theOwner); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.component.ts b/feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.component.ts new file mode 100644 index 00000000000..e11158b2ef4 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.component.ts @@ -0,0 +1,67 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ConfiguratorRouterExtractorService } from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { + distinctUntilKeyChanged, + filter, + switchMap, + take, +} from 'rxjs/operators'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { Configurator } from '../../core/model/configurator.model'; + +@Component({ + selector: 'cx-configurator-overview-form', + templateUrl: './configurator-overview-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorOverviewFormComponent { + configuration$: Observable< + Configurator.Configuration + > = this.configRouterExtractorService.extractRouterData().pipe( + switchMap((routerData) => + this.configuratorCommonsService.getOrCreateConfiguration(routerData.owner) + ), + distinctUntilKeyChanged('configId'), + switchMap((configuration) => + this.configuratorCommonsService.getConfigurationWithOverview( + configuration + ) + ), + filter((configuration) => configuration.overview != null) + ); + + constructor( + protected configuratorCommonsService: ConfiguratorCommonsService, + protected configRouterExtractorService: ConfiguratorRouterExtractorService + ) { + //In case the 'forceReload' is set (means the page is launched from the checkout in display only mode), + //we need to initialise the cart configuration + this.configRouterExtractorService + .extractRouterData() + .pipe(take(1)) + .subscribe((routingData) => { + if (routingData.forceReload) { + this.configuratorCommonsService.removeConfiguration( + routingData.owner + ); + } + }); + } + + /** + * Does the configuration contain any selected attribute values? + * @param configuration Current configuration + * @returns Any attributes available + */ + hasAttributes(configuration: Configurator.Configuration): boolean { + if (!(configuration?.overview?.groups?.length > 0)) { + return false; + } + return ( + configuration.overview.groups.find( + (group) => group.attributes?.length > 0 + ) !== undefined + ); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.module.ts b/feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.module.ts new file mode 100644 index 00000000000..ae7f41a3876 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-form/configurator-overview-form.module.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { CmsConfig, I18nModule, provideDefaultConfig } from '@spartacus/core'; +import { ConfiguratorOverviewAttributeModule } from '../overview-attribute/configurator-overview-attribute.module'; +import { ConfiguratorOverviewFormComponent } from './configurator-overview-form.component'; + +@NgModule({ + imports: [CommonModule, ConfiguratorOverviewAttributeModule, I18nModule], + providers: [ + provideDefaultConfig({ + cmsComponents: { + ConfiguratorOverviewForm: { + component: ConfiguratorOverviewFormComponent, + }, + }, + }), + ], + declarations: [ConfiguratorOverviewFormComponent], + exports: [ConfiguratorOverviewFormComponent], + entryComponents: [ConfiguratorOverviewFormComponent], +}) +export class ConfiguratorOverviewFormModule {} diff --git a/feature-libs/product-configurator/rulebased/components/overview-form/index.ts b/feature-libs/product-configurator/rulebased/components/overview-form/index.ts new file mode 100644 index 00000000000..ab73590a8cc --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-form/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-overview-form.component'; +export * from './configurator-overview-form.module'; diff --git a/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.html b/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.html new file mode 100644 index 00000000000..d5ffe4d3e50 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.html @@ -0,0 +1,27 @@ + + + +
+ {{ + 'configurator.notificationBanner.numberOfIssues' + | cxTranslate: { count: numberOfIssues } + }} + +
+
+
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 new file mode 100644 index 00000000000..e79ec230e6b --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.spec.ts @@ -0,0 +1,166 @@ +import { Component, Input, Pipe, PipeTransform } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + CommonConfigurator, + ConfiguratorRouterExtractorService, +} from '@spartacus/product-configurator/common'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorRouter } from '../../../common/components/service/configurator-router-data'; +import { CommonConfiguratorTestUtilsService } from '../../../common/shared/testing/common-configurator-test-utils.service'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { Configurator } from '../../core/model/configurator.model'; +import { + productConfiguration, + productConfigurationWithConflicts, + productConfigurationWithoutIssues, +} from '../../shared/testing/configurator-test-data'; +import { ConfiguratorOverviewNotificationBannerComponent } from './configurator-overview-notification-banner.component'; + +@Pipe({ + name: 'cxTranslate', +}) +class MockTranslatePipe implements PipeTransform { + transform(): any {} +} + +@Pipe({ + name: 'cxUrl', +}) +class MockUrlPipe implements PipeTransform { + transform(): any {} +} + +const configuratorType = 'cpqconfigurator'; + +const routerData: ConfiguratorRouter.Data = { + pageType: ConfiguratorRouter.PageType.OVERVIEW, + isOwnerCartEntry: true, + owner: { + type: CommonConfigurator.OwnerType.CART_ENTRY, + id: '3', + configuratorType: configuratorType, + }, +}; + +const orderRouterData: ConfiguratorRouter.Data = { + pageType: ConfiguratorRouter.PageType.OVERVIEW, + isOwnerCartEntry: true, + owner: { + type: CommonConfigurator.OwnerType.ORDER_ENTRY, + id: '3', + configuratorType: configuratorType, + }, +}; + +let routerObs; +class MockConfigRouterExtractorService { + extractRouterData() { + return routerObs; + } +} + +let configurationObs; +class MockConfiguratorCommonsService { + getConfiguration(): Observable { + return configurationObs; + } + getConfigurationWithOverview(): Observable { + return configurationObs; + } + removeConfiguration(): void {} +} +let component: ConfiguratorOverviewNotificationBannerComponent; +let fixture: ComponentFixture; +let htmlElem: HTMLElement; +function initialize(router: ConfiguratorRouter.Data) { + routerObs = of(router); + fixture = TestBed.createComponent( + ConfiguratorOverviewNotificationBannerComponent + ); + component = fixture.componentInstance; + htmlElem = fixture.nativeElement; + fixture.detectChanges(); +} +@Component({ + selector: 'cx-icon', + template: '', +}) +class MockCxIconComponent { + @Input() type; +} + +describe('ConfigOverviewNotificationBannerComponent', () => { + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + ConfiguratorOverviewNotificationBannerComponent, + MockTranslatePipe, + MockUrlPipe, + MockCxIconComponent, + ], + providers: [ + { + provide: ConfiguratorRouterExtractorService, + useClass: MockConfigRouterExtractorService, + }, + { + provide: ConfiguratorCommonsService, + useClass: MockConfiguratorCommonsService, + }, + ], + }).compileComponents(); + }) + ); + + it('should create', () => { + configurationObs = of(productConfiguration); + initialize(routerData); + expect(component).toBeTruthy(); + }); + + it('should display no banner when there are no issues', () => { + configurationObs = of(productConfigurationWithoutIssues); + initialize(routerData); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + 'cx-icon' + ); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + '.cx-error-msg' + ); + }); + + it('should display banner when there are issues', () => { + configurationObs = of(productConfigurationWithConflicts); + initialize(routerData); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'cx-icon' + ); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-error-msg' + ); + }); + + it('should display no banner in order history when there are issues', () => { + configurationObs = of(productConfiguration); + initialize(orderRouterData); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + 'cx-icon' + ); + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + '.cx-error-msg' + ); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.ts b/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.ts new file mode 100644 index 00000000000..42bf9bfcc34 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.component.ts @@ -0,0 +1,57 @@ +import { Component } from '@angular/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, + ConfiguratorRouter, + ConfiguratorRouterExtractorService, +} from '@spartacus/product-configurator/common'; +import { ICON_TYPE } from '@spartacus/storefront'; +import { Observable } from 'rxjs'; +import { + distinctUntilKeyChanged, + filter, + map, + switchMap, +} from 'rxjs/operators'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { Configurator } from '../../core/model/configurator.model'; + +@Component({ + selector: 'cx-configurator-overview-notification-banner', + templateUrl: './configurator-overview-notification-banner.component.html', +}) +export class ConfiguratorOverviewNotificationBannerComponent { + routerData$: Observable< + ConfiguratorRouter.Data + > = this.configRouterExtractorService.extractRouterData(); + + numberOfIssues$: Observable = this.routerData$.pipe( + filter( + (routerData) => + routerData.owner.type === CommonConfigurator.OwnerType.PRODUCT || + routerData.owner.type === CommonConfigurator.OwnerType.CART_ENTRY + ), + switchMap((routerData) => + this.configuratorCommonsService.getConfiguration(routerData.owner) + ), + distinctUntilKeyChanged('configId'), + map((configuration) => configuration.totalNumberOfIssues) + ); + + iconTypes = ICON_TYPE; + + constructor( + protected configuratorCommonsService: ConfiguratorCommonsService, + protected configRouterExtractorService: ConfiguratorRouterExtractorService, + protected commonConfigUtilsService: CommonConfiguratorUtilsService + ) {} + + protected countIssuesInGroup(group: Configurator.Group): number { + let numberOfIssues = 0; + group.attributes.forEach((attribute) => { + numberOfIssues = + numberOfIssues + (attribute.incomplete && attribute.required ? 1 : 0); + }); + return numberOfIssues; + } +} diff --git a/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.module.ts b/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.module.ts new file mode 100644 index 00000000000..f2b8a24679f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-notification-banner/configurator-overview-notification-banner.module.ts @@ -0,0 +1,28 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { + CmsConfig, + I18nModule, + provideDefaultConfig, + UrlModule, +} from '@spartacus/core'; +import { IconModule } from '@spartacus/storefront'; +import { ConfiguratorOverviewNotificationBannerComponent } from './configurator-overview-notification-banner.component'; + +@NgModule({ + imports: [CommonModule, I18nModule, UrlModule, IconModule, RouterModule], + providers: [ + provideDefaultConfig({ + cmsComponents: { + ConfiguratorOverviewBanner: { + component: ConfiguratorOverviewNotificationBannerComponent, + }, + }, + }), + ], + declarations: [ConfiguratorOverviewNotificationBannerComponent], + exports: [ConfiguratorOverviewNotificationBannerComponent], + entryComponents: [ConfiguratorOverviewNotificationBannerComponent], +}) +export class ConfiguratorOverviewNotificationBannerModule {} diff --git a/feature-libs/product-configurator/rulebased/components/overview-notification-banner/index.ts b/feature-libs/product-configurator/rulebased/components/overview-notification-banner/index.ts new file mode 100644 index 00000000000..68f613c167a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/overview-notification-banner/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-overview-notification-banner.component'; +export * from './configurator-overview-notification-banner.module'; diff --git a/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.html b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.html new file mode 100644 index 00000000000..d99234cb6ce --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.html @@ -0,0 +1,20 @@ + + + + + + diff --git a/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.spec.ts b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.spec.ts new file mode 100644 index 00000000000..63f5a1decf1 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.spec.ts @@ -0,0 +1,294 @@ +import { ChangeDetectionStrategy, Directive, Input, Type } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + I18nTestingModule, + RouterState, + RoutingService, +} from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { cold } from 'jasmine-marbles'; +import { Observable, of } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { TestScheduler } from 'rxjs/testing'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { ConfiguratorGroupsService } from '../../core/facade/configurator-groups.service'; +import { Configurator } from '../../core/model/configurator.model'; +import * as ConfigurationTestData from '../../shared/testing/configurator-test-data'; +import { + GROUP_ID_1, + PRODUCT_CODE, +} from '../../shared/testing/configurator-test-data'; +import { ConfiguratorStorefrontUtilsService } from '../service/configurator-storefront-utils.service'; +import { ConfiguratorPreviousNextButtonsComponent } from './configurator-previous-next-buttons.component'; + +let routerStateObservable = null; + +class MockRoutingService { + getRouterState(): Observable { + return routerStateObservable; + } +} + +class MockConfiguratorGroupsService { + getCurrentGroupId() { + return of(''); + } + getNextGroupId() { + return of(''); + } + getPreviousGroupId() { + return of(''); + } + navigateToGroup() {} +} + +const groups: Configurator.Group = { + id: GROUP_ID_1, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + attributes: [], + subGroups: [], +}; + +const configWithoutGroups: Configurator.Configuration = { + configId: 'CONFIG_ID', + productCode: PRODUCT_CODE, + totalNumberOfIssues: 0, + owner: { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.PRODUCT, + }, + groups: [groups], + flatGroups: [groups], +}; + +const config: Configurator.Configuration = + ConfigurationTestData.productConfiguration; + +class MockConfiguratorCommonsService { + getConfiguration(): Observable { + return of(config); + } +} + +class MockConfigUtilsService { + scrollToConfigurationElement(): void {} +} + +@Directive({ + selector: '[cxFocus]', +}) +export class MockFocusDirective { + @Input('cxFocus') protected config; +} + +describe('ConfigPreviousNextButtonsComponent', () => { + let classUnderTest: ConfiguratorPreviousNextButtonsComponent; + let fixture: ComponentFixture; + let configuratorCommonsService: ConfiguratorCommonsService; + let configurationGroupsService: ConfiguratorGroupsService; + let configuratorUtils: CommonConfiguratorUtilsService; + + beforeEach( + waitForAsync(() => { + routerStateObservable = of(ConfigurationTestData.mockRouterState); + TestBed.configureTestingModule({ + imports: [I18nTestingModule], + declarations: [ + ConfiguratorPreviousNextButtonsComponent, + MockFocusDirective, + ], + providers: [ + { + provide: RoutingService, + useClass: MockRoutingService, + }, + { + provide: ConfiguratorGroupsService, + useClass: MockConfiguratorGroupsService, + }, + { + provide: ConfiguratorCommonsService, + useClass: MockConfiguratorCommonsService, + }, + { + provide: ConfiguratorStorefrontUtilsService, + useClass: MockConfigUtilsService, + }, + ], + }) + .overrideComponent(ConfiguratorPreviousNextButtonsComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorPreviousNextButtonsComponent); + classUnderTest = fixture.componentInstance; + configuratorCommonsService = TestBed.inject( + ConfiguratorCommonsService as Type + ); + configurationGroupsService = TestBed.inject( + ConfiguratorGroupsService as Type + ); + fixture.detectChanges(); + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + configuratorUtils.setOwnerKey(config.owner); + }); + + it('should create', () => { + expect(classUnderTest).toBeTruthy(); + }); + + it("should not display 'previous' & 'next' buttons", () => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(configWithoutGroups) + ); + fixture = TestBed.createComponent(ConfiguratorPreviousNextButtonsComponent); + classUnderTest = fixture.componentInstance; + fixture.detectChanges(); + expect(fixture.nativeElement.childElementCount).toBe(0); + }); + + it('should display previous button as disabled if it is the first group', () => { + spyOn(configurationGroupsService, 'getPreviousGroupId').and.returnValue( + of(null) + ); + fixture.detectChanges(); + const prevBtn = fixture.debugElement.query(By.css('.btn-action')) + .nativeElement; + expect(prevBtn.disabled).toBe(true); + }); + + it('should display previous button as enabled if it is not the first group', () => { + spyOn(configurationGroupsService, 'getPreviousGroupId').and.returnValue( + of('anyGroupId') + ); + fixture.detectChanges(); + const prevBtn = fixture.debugElement.query(By.css('.btn-action')) + .nativeElement; + expect(prevBtn.disabled).toBe(false); + }); + + it('should display next button as disabled if it is the last group', () => { + spyOn(configurationGroupsService, 'getNextGroupId').and.returnValue( + of(null) + ); + fixture.detectChanges(); + const lastBtn = fixture.debugElement.query(By.css('.btn-secondary')) + .nativeElement; + expect(lastBtn.disabled).toBe(true); + }); + + it('should display next button as enabled if it is not the last group', () => { + spyOn(configurationGroupsService, 'getNextGroupId').and.returnValue( + of('anyGroupId') + ); + fixture.detectChanges(); + const prevBtn = fixture.debugElement.query(By.css('.btn-secondary')) + .nativeElement; + expect(prevBtn.disabled).toBe(false); + }); + + it('should derive that current group is last group depending on group service nextGroup function', () => { + const nextGroup = cold('-a-b-c', { + a: ConfigurationTestData.GROUP_ID_1, + b: ConfigurationTestData.GROUP_ID_2, + c: null, + }); + + spyOn(configurationGroupsService, 'getNextGroupId').and.returnValue( + nextGroup + ); + + expect(classUnderTest.isLastGroup(config.owner)).toBeObservable( + cold('-a-b-c', { + a: false, + b: false, + c: true, + }) + ); + }); + + it('should derive that current group is first group depending on group service getPreviousGroup function', () => { + const previousGroup = cold('-a-b-c-d-e', { + a: null, + b: ConfigurationTestData.GROUP_ID_2, + c: null, + d: '', + e: ' ', + }); + + spyOn(configurationGroupsService, 'getPreviousGroupId').and.returnValue( + previousGroup + ); + + expect(classUnderTest.isFirstGroup(config.owner)).toBeObservable( + cold('-a-b-c-d-e', { + a: true, + b: false, + c: true, + d: true, + e: false, + }) + ); + }); + + it('should navigate to group exactly one time on navigateToPreviousGroup', () => { + //usage of TestScheduler because of the async check in last line + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + testScheduler.run((helpers) => { + const { expectObservable } = helpers; + const previousGroup = cold('-a-b', { + a: ConfigurationTestData.GROUP_ID_1, + b: ConfigurationTestData.GROUP_ID_2, + }); + //this just validates the testScheduler + expectObservable(previousGroup.pipe(take(1))).toBe('-(a|)', { + a: ConfigurationTestData.GROUP_ID_1, + }); + + spyOn(configurationGroupsService, 'getPreviousGroupId').and.returnValue( + previousGroup + ); + spyOn(configurationGroupsService, 'navigateToGroup'); + + classUnderTest.onPrevious(config); + }); + //this is the actual test + expect(configurationGroupsService.navigateToGroup).toHaveBeenCalledTimes(1); + }); + + it('should navigate to group exactly one time on navigateToNextGroup', () => { + //usage of TestScheduler because of the async check in last line + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + testScheduler.run(() => { + const nextGroup = cold('-a-b', { + a: ConfigurationTestData.GROUP_ID_1, + b: ConfigurationTestData.GROUP_ID_2, + }); + + spyOn(configurationGroupsService, 'getNextGroupId').and.returnValue( + nextGroup + ); + spyOn(configurationGroupsService, 'navigateToGroup'); + + classUnderTest.onNext(config); + }); + + expect(configurationGroupsService.navigateToGroup).toHaveBeenCalledTimes(1); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.ts b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.ts new file mode 100644 index 00000000000..4ec44fe401b --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.ts @@ -0,0 +1,73 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + CommonConfigurator, + ConfiguratorRouterExtractorService, +} from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { ConfiguratorGroupsService } from '../../core/facade/configurator-groups.service'; +import { Configurator } from '../../core/model/configurator.model'; +import { ConfiguratorStorefrontUtilsService } from '../service/configurator-storefront-utils.service'; + +@Component({ + selector: 'cx-configurator-previous-next-buttons', + templateUrl: './configurator-previous-next-buttons.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorPreviousNextButtonsComponent { + configuration$: Observable< + Configurator.Configuration + > = this.configRouterExtractorService + .extractRouterData() + .pipe( + switchMap((routerData) => + this.configuratorCommonsService.getConfiguration(routerData.owner) + ) + ); + + constructor( + protected configuratorGroupsService: ConfiguratorGroupsService, + protected configuratorCommonsService: ConfiguratorCommonsService, + protected configRouterExtractorService: ConfiguratorRouterExtractorService, + protected configUtils: ConfiguratorStorefrontUtilsService + ) {} + + onPrevious(configuration: Configurator.Configuration): void { + this.configuratorGroupsService + .getPreviousGroupId(configuration.owner) + .pipe(take(1)) + .subscribe((groupId) => + this.configuratorGroupsService.navigateToGroup(configuration, groupId) + ); + + this.configUtils.scrollToConfigurationElement( + '.VariantConfigurationTemplate' + ); + } + + onNext(configuration: Configurator.Configuration): void { + this.configuratorGroupsService + .getNextGroupId(configuration.owner) + .pipe(take(1)) + .subscribe((groupId) => + this.configuratorGroupsService.navigateToGroup(configuration, groupId) + ); + + this.configUtils.scrollToConfigurationElement( + '.VariantConfigurationTemplate' + ); + } + + isFirstGroup(owner: CommonConfigurator.Owner): Observable { + return this.configuratorGroupsService + .getPreviousGroupId(owner) + .pipe(map((group) => !group)); + } + + isLastGroup(owner: CommonConfigurator.Owner): Observable { + return this.configuratorGroupsService + .getNextGroupId(owner) + .pipe(map((group) => !group)); + } +} diff --git a/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.module.ts b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.module.ts new file mode 100644 index 00000000000..3b874771afe --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.module.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { CmsConfig, I18nModule, provideDefaultConfig } from '@spartacus/core'; +import { KeyboardFocusModule } from '@spartacus/storefront'; +import { ConfiguratorPreviousNextButtonsComponent } from './configurator-previous-next-buttons.component'; + +@NgModule({ + imports: [CommonModule, I18nModule, KeyboardFocusModule], + providers: [ + provideDefaultConfig({ + cmsComponents: { + ConfiguratorPrevNext: { + component: ConfiguratorPreviousNextButtonsComponent, + }, + }, + }), + ], + declarations: [ConfiguratorPreviousNextButtonsComponent], + exports: [ConfiguratorPreviousNextButtonsComponent], + entryComponents: [ConfiguratorPreviousNextButtonsComponent], +}) +export class ConfiguratorPreviousNextButtonsModule {} diff --git a/feature-libs/product-configurator/rulebased/components/previous-next-buttons/index.ts b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/index.ts new file mode 100644 index 00000000000..a664d6a01df --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-previous-next-buttons.component'; +export * from './configurator-previous-next-buttons.module'; diff --git a/feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.component.html b/feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.component.html new file mode 100644 index 00000000000..63b4ab50df1 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.component.html @@ -0,0 +1,30 @@ + +
+
+
+
+ {{ 'configurator.priceSummary.basePrice' | cxTranslate }}: +
+
+ {{ configuration?.priceSummary?.basePrice?.formattedValue }} +
+
+
+
+ {{ 'configurator.priceSummary.selectedOptions' | cxTranslate }}: +
+
+ {{ configuration?.priceSummary?.selectedOptions?.formattedValue }} +
+
+
+
+ {{ 'configurator.priceSummary.totalPrice' | cxTranslate }}: +
+
+ {{ configuration?.priceSummary?.currentTotal?.formattedValue }} +
+
+
+
+
diff --git a/feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.component.spec.ts b/feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.component.spec.ts new file mode 100644 index 00000000000..93af574a7a2 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.component.spec.ts @@ -0,0 +1,107 @@ +import { ChangeDetectionStrategy } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + I18nTestingModule, + RouterState, + RoutingService, +} from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { Configurator } from '../../core/model/configurator.model'; +import { ConfiguratorPriceSummaryComponent } from './configurator-price-summary.component'; + +const PRODUCT_CODE = 'CONF_LAPTOP'; + +const mockRouterState: any = { + state: { + params: { + entityKey: PRODUCT_CODE, + ownerType: CommonConfigurator.OwnerType.PRODUCT, + }, + queryParams: {}, + semanticRoute: 'configureOverviewCPQCONFIGURATOR', + }, +}; + +const config: Configurator.Configuration = { + configId: '1234-56-7890', + consistent: true, + complete: true, + productCode: PRODUCT_CODE, + priceSummary: { + basePrice: { + formattedValue: '22.000 €', + }, + selectedOptions: { + formattedValue: '900 €', + }, + currentTotal: { + formattedValue: '22.900 €', + }, + }, +}; + +let routerStateObservable = null; +class MockRoutingService { + getRouterState(): Observable { + return routerStateObservable; + } +} + +class MockConfiguratorCommonsService { + getConfiguration(): Observable { + return of(config); + } +} + +describe('ConfigPriceSummaryComponent', () => { + let component: ConfiguratorPriceSummaryComponent; + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + routerStateObservable = of(mockRouterState); + TestBed.configureTestingModule({ + imports: [I18nTestingModule], + declarations: [ConfiguratorPriceSummaryComponent], + providers: [ + { + provide: ConfiguratorCommonsService, + useClass: MockConfiguratorCommonsService, + }, + + { + provide: RoutingService, + useClass: MockRoutingService, + }, + ], + }) + .overrideComponent(ConfiguratorPriceSummaryComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorPriceSummaryComponent); + component = fixture.componentInstance; + }); + + it('should create component', () => { + expect(component).toBeDefined(); + }); + + it('should get product code and prices as part of product configuration', () => { + component.configuration$ + .subscribe((data: Configurator.Configuration) => { + expect(data.productCode).toEqual(PRODUCT_CODE); + expect(data.priceSummary.basePrice).toEqual( + config.priceSummary.basePrice + ); + }) + .unsubscribe(); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.component.ts b/feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.component.ts new file mode 100644 index 00000000000..315b9660793 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ConfiguratorRouterExtractorService } from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { Configurator } from '../../core/model/configurator.model'; + +@Component({ + selector: 'cx-configurator-price-summary', + templateUrl: './configurator-price-summary.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorPriceSummaryComponent { + configuration$: Observable< + Configurator.Configuration + > = this.configRouterExtractorService.extractRouterData().pipe( + switchMap((routerData) => { + return this.configuratorCommonsService.getConfiguration(routerData.owner); + }) + ); + + constructor( + protected configuratorCommonsService: ConfiguratorCommonsService, + protected configRouterExtractorService: ConfiguratorRouterExtractorService + ) {} +} diff --git a/feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.module.ts b/feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.module.ts new file mode 100644 index 00000000000..c0482235555 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/price-summary/configurator-price-summary.module.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { CmsConfig, I18nModule, provideDefaultConfig } from '@spartacus/core'; +import { ConfiguratorPriceSummaryComponent } from './configurator-price-summary.component'; + +@NgModule({ + imports: [FormsModule, ReactiveFormsModule, CommonModule, I18nModule], + providers: [ + provideDefaultConfig({ + cmsComponents: { + ConfiguratorPriceSummary: { + component: ConfiguratorPriceSummaryComponent, + }, + }, + }), + ], + declarations: [ConfiguratorPriceSummaryComponent], + exports: [ConfiguratorPriceSummaryComponent], + entryComponents: [ConfiguratorPriceSummaryComponent], +}) +export class ConfiguratorPriceSummaryModule {} diff --git a/feature-libs/product-configurator/rulebased/components/price-summary/index.ts b/feature-libs/product-configurator/rulebased/components/price-summary/index.ts new file mode 100644 index 00000000000..1110f921a8f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/price-summary/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-price-summary.component'; +export * from './configurator-price-summary.module'; diff --git a/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.html b/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.html new file mode 100644 index 00000000000..d6a7701f39c --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.html @@ -0,0 +1,42 @@ + +
+
+ {{ product.name }} +
+ + + + + + + + + + + +
+
+ +
+
+
+ {{ product.name }} +
+
+ {{ product.code }} +
+ +
+ {{ product.description }} +
+
+
+
+
diff --git a/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.spec.ts b/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.spec.ts new file mode 100644 index 00000000000..cd8eca9ce3f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.spec.ts @@ -0,0 +1,253 @@ +import { Component, Input, Type } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Router, RouterState } from '@angular/router'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { + I18nTestingModule, + Product, + ProductService, + RoutingService, +} from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorTestUtilsService, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { IconLoaderService } from '@spartacus/storefront'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { Configurator } from '../../core/model/configurator.model'; +import { ConfiguratorProductTitleComponent } from './configurator-product-title.component'; + +const PRODUCT_CODE = 'CONF_LAPTOP'; +const PRODUCT_NAME = 'productName'; +const CONFIG_ID = '12342'; +const CONFIGURATOR_ROUTE = 'configureCPQCONFIGURATOR'; + +const mockRouterState: any = { + state: { + params: { + entityKey: PRODUCT_CODE, + ownerType: CommonConfigurator.OwnerType.PRODUCT, + }, + semanticRoute: CONFIGURATOR_ROUTE, + }, +}; + +const config: Configurator.Configuration = { + owner: { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.PRODUCT, + }, + configId: CONFIG_ID, + productCode: PRODUCT_CODE, +}; + +const orderEntryconfig: Configurator.Configuration = { + owner: { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.ORDER_ENTRY, + }, + configId: CONFIG_ID, + overview: { + productCode: PRODUCT_CODE, + }, +}; + +const imageURL = 'some URL'; +const altText = 'some text'; + +const product: Product = { + name: PRODUCT_NAME, + code: PRODUCT_CODE, + images: { + PRIMARY: { + thumbnail: { + url: imageURL, + altText: altText, + }, + }, + }, + price: { + formattedValue: '$1.500', + }, + priceRange: { + maxPrice: { + formattedValue: '$1.500', + }, + minPrice: { + formattedValue: '$1.000', + }, + }, +}; +let configuration: Configurator.Configuration; + +class MockRoutingService { + getRouterState(): Observable { + return of(mockRouterState); + } +} + +class MockRouter { + public events = of(''); +} + +class MockProductService { + get(): Observable { + return of(product); + } +} + +class MockConfiguratorCommonsService { + getConfiguration(): Observable { + return of(configuration); + } +} + +export class MockIconFontLoaderService { + getFlipDirection(): void {} +} + +@Component({ + selector: 'cx-icon', + template: '', +}) +class MockCxIconComponent { + @Input() type; +} + +describe('ConfigProductTitleComponent', () => { + let component: ConfiguratorProductTitleComponent; + let fixture: ComponentFixture; + let configuratorUtils: CommonConfiguratorUtilsService; + let htmlElem: HTMLElement; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [I18nTestingModule, ReactiveFormsModule, NgSelectModule], + declarations: [ConfiguratorProductTitleComponent, MockCxIconComponent], + providers: [ + { + provide: Router, + useClass: MockRouter, + }, + { + provide: RoutingService, + useClass: MockRoutingService, + }, + { + provide: ConfiguratorCommonsService, + useClass: MockConfiguratorCommonsService, + }, + { + provide: ProductService, + useClass: MockProductService, + }, + { provide: IconLoaderService, useClass: MockIconFontLoaderService }, + ], + }); + }) + ); + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorProductTitleComponent); + htmlElem = fixture.nativeElement; + component = fixture.componentInstance; + + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + configuratorUtils.setOwnerKey(config.owner); + configuratorUtils.setOwnerKey(orderEntryconfig.owner); + configuration = config; + + fixture.detectChanges(); + }); + + it('should create component', () => { + expect(component).toBeDefined(); + }); + + it('should get product name as part of product of configuration', () => { + component.product$.subscribe((data: Product) => { + expect(data.name).toEqual(PRODUCT_NAME); + }); + }); + + it('should get product name as part of product of overview configuration', () => { + configuration = orderEntryconfig; + component.product$.subscribe((data: Product) => { + expect(data.name).toEqual(PRODUCT_NAME); + }); + }); + + it('should render initial content properly', () => { + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-title' + ); + CommonConfiguratorTestUtilsService.expectElementToContainText( + expect, + htmlElem, + '.cx-title', + PRODUCT_NAME + ); + + CommonConfiguratorTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + '.cx-details.open' + ); + CommonConfiguratorTestUtilsService.expectElementToContainText( + expect, + htmlElem, + '.cx-toggle-details-link-text', + 'configurator.header.showMore' //Check translation key, because translation module is not available + ); + }); + + it('should render show more case - default', () => { + component.triggerDetails(); + fixture.detectChanges(); + + expect(component.showMore).toBe(true); + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-details.open' + ); + + CommonConfiguratorTestUtilsService.expectElementToContainText( + expect, + htmlElem, + '.cx-toggle-details-link-text', + 'configurator.header.showLess' //Check translation key, because translation module is not available + ); + }); + + it('should render properly for navigation from order entry', () => { + configuration = orderEntryconfig; + CommonConfiguratorTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-title' + ); + CommonConfiguratorTestUtilsService.expectElementToContainText( + expect, + htmlElem, + '.cx-title', + PRODUCT_NAME + ); + }); + + it('should return undefined for getProductImageURL/Alttext if not properly defined', () => { + product.images.PRIMARY = {}; + expect(component.getProductImageURL(product)).toBeUndefined(); + product.images = {}; + expect(component.getProductImageURL(product)).toBeUndefined(); + expect(component.getProductImageURL({})).toBeUndefined(); + expect(component.getProductImageAlt({})).toBeUndefined(); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.ts b/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.ts new file mode 100644 index 00000000000..713ccf8e877 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.ts @@ -0,0 +1,54 @@ +import { Component } from '@angular/core'; +import { Product, ProductService } from '@spartacus/core'; +import { + CommonConfigurator, + ConfiguratorRouterExtractorService, +} from '@spartacus/product-configurator/common'; +import { ICON_TYPE } from '@spartacus/storefront'; +import { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; + +@Component({ + selector: 'cx-configurator-product-title', + templateUrl: './configurator-product-title.component.html', +}) +export class ConfiguratorProductTitleComponent { + product$: Observable< + Product + > = this.configRouterExtractorService.extractRouterData().pipe( + switchMap((routerData) => + this.configuratorCommonsService.getConfiguration(routerData.owner) + ), + map((configuration) => { + switch (configuration.owner.type) { + case CommonConfigurator.OwnerType.PRODUCT: + case CommonConfigurator.OwnerType.CART_ENTRY: + return configuration.productCode; + case CommonConfigurator.OwnerType.ORDER_ENTRY: + return configuration.overview.productCode; + } + }), + switchMap((productCode) => this.productService.get(productCode)) + ); + showMore = false; + iconTypes = ICON_TYPE; + + constructor( + protected configuratorCommonsService: ConfiguratorCommonsService, + protected configRouterExtractorService: ConfiguratorRouterExtractorService, + protected productService: ProductService + ) {} + + triggerDetails(): void { + this.showMore = !this.showMore; + } + + getProductImageURL(product: Product): string { + return product.images?.PRIMARY?.['thumbnail']?.url; + } + + getProductImageAlt(product: Product): string { + return product.images?.PRIMARY?.['thumbnail']?.altText; + } +} diff --git a/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.module.ts b/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.module.ts new file mode 100644 index 00000000000..06ecebe9e67 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.module.ts @@ -0,0 +1,31 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { CmsConfig, I18nModule, provideDefaultConfig } from '@spartacus/core'; +import { IconModule } from '@spartacus/storefront'; +import { ConfiguratorProductTitleComponent } from './configurator-product-title.component'; + +@NgModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + NgSelectModule, + CommonModule, + I18nModule, + IconModule, + ], + providers: [ + provideDefaultConfig({ + cmsComponents: { + ConfiguratorProductTitle: { + component: ConfiguratorProductTitleComponent, + }, + }, + }), + ], + declarations: [ConfiguratorProductTitleComponent], + exports: [ConfiguratorProductTitleComponent], + entryComponents: [ConfiguratorProductTitleComponent], +}) +export class ConfiguratorProductTitleModule {} diff --git a/feature-libs/product-configurator/rulebased/components/product-title/index.ts b/feature-libs/product-configurator/rulebased/components/product-title/index.ts new file mode 100644 index 00000000000..a4bdd60014a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/product-title/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-product-title.component'; +export * from './configurator-product-title.module'; diff --git a/feature-libs/product-configurator/rulebased/components/rulebased-configurator-components.module.ts b/feature-libs/product-configurator/rulebased/components/rulebased-configurator-components.module.ts new file mode 100644 index 00000000000..2e286244b3f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/rulebased-configurator-components.module.ts @@ -0,0 +1,31 @@ +import { NgModule } from '@angular/core'; +import { ConfiguratorAddToCartButtonModule } from './add-to-cart-button/configurator-add-to-cart-button.module'; +import { ConfiguratorFormModule } from './form/configurator-form.module'; +import { ConfiguratorGroupMenuModule } from './group-menu/configurator-group-menu.module'; +import { ConfiguratorGroupTitleModule } from './group-title/configurator-group-title.module'; +import { ConfiguratorOverviewAttributeModule } from './overview-attribute/configurator-overview-attribute.module'; +import { ConfiguratorOverviewFormModule } from './overview-form/configurator-overview-form.module'; +import { ConfiguratorOverviewNotificationBannerModule } from './overview-notification-banner/configurator-overview-notification-banner.module'; +import { ConfiguratorPreviousNextButtonsModule } from './previous-next-buttons/configurator-previous-next-buttons.module'; +import { ConfiguratorPriceSummaryModule } from './price-summary/configurator-price-summary.module'; +import { ConfiguratorProductTitleModule } from './product-title/configurator-product-title.module'; +import { ConfiguratorTabBarModule } from './tab-bar/configurator-tab-bar.module'; +import { ConfiguratorUpdateMessageModule } from './update-message/configurator-update-message.module'; + +@NgModule({ + imports: [ + ConfiguratorPriceSummaryModule, + ConfiguratorAddToCartButtonModule, + ConfiguratorGroupMenuModule, + ConfiguratorProductTitleModule, + ConfiguratorTabBarModule, + ConfiguratorFormModule, + ConfiguratorGroupTitleModule, + ConfiguratorUpdateMessageModule, + ConfiguratorPreviousNextButtonsModule, + ConfiguratorOverviewAttributeModule, + ConfiguratorOverviewFormModule, + ConfiguratorOverviewNotificationBannerModule, + ], +}) +export class RulebasedConfiguratorComponentsModule {} diff --git a/feature-libs/product-configurator/rulebased/components/service/configurator-storefront-utils.service.spec.ts b/feature-libs/product-configurator/rulebased/components/service/configurator-storefront-utils.service.spec.ts new file mode 100644 index 00000000000..8cbd3c247e3 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/service/configurator-storefront-utils.service.spec.ts @@ -0,0 +1,102 @@ +import { TestBed } from '@angular/core/testing'; +import { FormControl } from '@angular/forms'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorGroupsService } from '../../core/facade/configurator-groups.service'; +import { Configurator } from '../../core/model/configurator.model'; +import { ConfiguratorStorefrontUtilsService } from './configurator-storefront-utils.service'; + +let isGroupVisited: Observable = of(false); + +class MockConfiguratorGroupsService { + isGroupVisited(): Observable { + return isGroupVisited; + } +} + +describe('ConfigUtilsService', () => { + let classUnderTest: ConfiguratorStorefrontUtilsService; + + const owner: CommonConfigurator.Owner = { + id: 'testProduct', + type: CommonConfigurator.OwnerType.PRODUCT, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: ConfiguratorGroupsService, + useClass: MockConfiguratorGroupsService, + }, + ], + }); + classUnderTest = TestBed.inject(ConfiguratorStorefrontUtilsService); + }); + + it('should be created', () => { + expect(classUnderTest).toBeTruthy(); + }); + + function getCurrentResult() { + let result: boolean; + classUnderTest + .isCartEntryOrGroupVisited(owner, 'group_01') + .subscribe((data) => (result = data)) + .unsubscribe(); + return result; + } + + it('should scroll to element', () => { + const theElement = document.createElement('div'); + document.querySelector = jasmine + .createSpy('HTML Element') + .and.returnValue(theElement); + spyOn(theElement, 'getBoundingClientRect').and.returnValue( + new DOMRect(100, 2000, 100, 100) + ); + spyOn(window, 'scroll').and.callThrough(); + classUnderTest.scrollToConfigurationElement( + '.VariantConfigurationTemplate' + ); + expect(window.scroll).toHaveBeenCalledWith(0, 0); + }); + + it('should return false because the product has not been added to the cart and the current group was not visited', () => { + isGroupVisited = of(false); + owner.type = CommonConfigurator.OwnerType.PRODUCT; + expect(getCurrentResult()).toBe(false); + }); + + it('should return true because the product has been added to the cart', () => { + isGroupVisited = of(false); + owner.type = CommonConfigurator.OwnerType.CART_ENTRY; + expect(getCurrentResult()).toBe(true); + }); + + it('should return true because the current group was visited', () => { + isGroupVisited = of(true); + expect(getCurrentResult()).toBe(true); + }); + + it('should assemble values from a checkbox list into an attribute value', () => { + const controlArray = new Array(); + const control1 = new FormControl(true); + const control2 = new FormControl(false); + controlArray.push(control1, control2); + const attribute: Configurator.Attribute = { + name: 'attr', + values: [{ valueCode: 'b' }, { name: 'blue' }], + }; + + const values: Configurator.Value[] = classUnderTest.assembleValuesForMultiSelectAttributes( + controlArray, + attribute + ); + expect(values.length).toBe(2); + expect(values[0].valueCode).toBe(attribute.values[0].valueCode); + expect(values[0].selected).toBe(true); + expect(values[1].name).toBe(attribute.values[1].name); + expect(values[1].selected).toBe(false); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/service/configurator-storefront-utils.service.ts b/feature-libs/product-configurator/rulebased/components/service/configurator-storefront-utils.service.ts new file mode 100644 index 00000000000..265b73c6385 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/service/configurator-storefront-utils.service.ts @@ -0,0 +1,108 @@ +import { isPlatformBrowser } from '@angular/common'; +import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { ConfiguratorGroupsService } from '../../core/facade/configurator-groups.service'; +import { Configurator } from '../../core/model/configurator.model'; + +@Injectable({ + providedIn: 'root', +}) +export class ConfiguratorStorefrontUtilsService { + constructor( + protected configuratorGroupsService: ConfiguratorGroupsService, + @Inject(PLATFORM_ID) protected platformId: any + ) {} + + /** + * Does the configuration belong to a cart entry, or has the group been visited already? + * In both cases we need to render indications for mandatory attributes. + * This method emits only once and then stops further emissions. + * + * @param {CommonConfigurator.Owner} owner - + * @param {string} groupId - Group ID + * @return {Observable} - Returns 'Observable' if the cart entry or group are visited, otherwise 'Observable' + */ + isCartEntryOrGroupVisited( + owner: CommonConfigurator.Owner, + groupId: string + ): Observable { + return this.configuratorGroupsService.isGroupVisited(owner, groupId).pipe( + take(1), + map((result) => + result ? true : owner.type === CommonConfigurator.OwnerType.CART_ENTRY + ) + ); + } + + /** + * Assemble an attribute value with the currently selected values from a checkbox list. + * + * @param {FormControl[]} controlArray - Control array + * @param {Configurator.Attribute} attribute - Configuration attribute + * @return {Configurator.Value[]} - list of configurator values + */ + assembleValuesForMultiSelectAttributes( + controlArray: FormControl[], + attribute: Configurator.Attribute + ): Configurator.Value[] { + const localAssembledValues: Configurator.Value[] = []; + + for (let i = 0; i < controlArray.length; i++) { + const localAttributeValue: Configurator.Value = {}; + localAttributeValue.valueCode = attribute.values[i].valueCode; + localAttributeValue.name = attribute.values[i].name; + localAttributeValue.selected = controlArray[i].value; + localAssembledValues.push(localAttributeValue); + } + return localAssembledValues; + } + + /** + * Verifies whether the HTML element is in the viewport. + * + * @param {Element} element - HTML element + * @return {boolean} Returns 'true' if the HTML element is in the viewport, otherwise 'false' + */ + protected isInViewport(element: Element): boolean { + const bounding = element.getBoundingClientRect(); + return ( + bounding.top >= 0 && + bounding.left >= 0 && + bounding.bottom <= + (window.innerHeight || document.documentElement.clientHeight) && + bounding.right <= + (window.innerWidth || document.documentElement.clientWidth) + ); + } + + /** + * Scrolls to the corresponding HTML element. + * + * @param {Element | HTMLElement} element - HTML element + */ + protected scroll(element: Element | HTMLElement): void { + let topOffset = 0; + if (element instanceof HTMLElement) { + topOffset = element.offsetTop; + } + window.scroll(0, topOffset); + } + + /** + * Scrolls to the corresponding configuration element in the HTML tree. + * + * @param {string} selector - Selector of the HTML element + */ + scrollToConfigurationElement(selector: string): void { + if (isPlatformBrowser(this.platformId)) { + // we don't want to run this logic when doing SSR + const element = document.querySelector(selector); + if (element && !this.isInViewport(element)) { + this.scroll(element); + } + } + } +} diff --git a/feature-libs/product-configurator/rulebased/components/service/index.ts b/feature-libs/product-configurator/rulebased/components/service/index.ts new file mode 100644 index 00000000000..20ed61a53b1 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/service/index.ts @@ -0,0 +1 @@ +export * from './configurator-storefront-utils.service'; diff --git a/feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.component.html b/feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.component.html new file mode 100644 index 00000000000..11dea7f4c2f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.component.html @@ -0,0 +1,34 @@ + + + {{ 'configurator.tabBar.configuration' | cxTranslate }} + {{ 'configurator.tabBar.overview' | cxTranslate }} + + diff --git a/feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.component.spec.ts b/feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.component.spec.ts new file mode 100644 index 00000000000..6365294e474 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.component.spec.ts @@ -0,0 +1,106 @@ +import { ChangeDetectionStrategy, Pipe, PipeTransform } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { + I18nTestingModule, + RouterState, + RoutingService, +} from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorTabBarComponent } from './configurator-tab-bar.component'; + +const PRODUCT_CODE = 'CONF_LAPTOP'; +const CONFIG_OVERVIEW_ROUTE = 'configureOverviewCPQCONFIGURATOR'; +const CONFIGURATOR_ROUTE = 'configureCPQCONFIGURATOR'; + +const mockRouterState: any = { + state: { + params: { + entityKey: PRODUCT_CODE, + ownerType: CommonConfigurator.OwnerType.PRODUCT, + }, + queryParams: {}, + semanticRoute: CONFIG_OVERVIEW_ROUTE, + }, +}; + +let routerStateObservable = null; + +class MockRoutingService { + getRouterState(): Observable { + return routerStateObservable; + } +} + +@Pipe({ + name: 'cxUrl', +}) +class MockUrlPipe implements PipeTransform { + transform(): any {} +} + +describe('ConfigTabBarComponent', () => { + let component: ConfiguratorTabBarComponent; + let fixture: ComponentFixture; + let htmlElem: HTMLElement; + + beforeEach( + waitForAsync(() => { + mockRouterState.state.params.displayOnly = false; + + routerStateObservable = of(mockRouterState); + TestBed.configureTestingModule({ + imports: [I18nTestingModule, RouterTestingModule], + declarations: [ConfiguratorTabBarComponent, MockUrlPipe], + providers: [ + { + provide: RoutingService, + useClass: MockRoutingService, + }, + ], + }) + .overrideComponent(ConfiguratorTabBarComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorTabBarComponent); + component = fixture.componentInstance; + htmlElem = fixture.nativeElement; + }); + + it('should create component', () => { + expect(component).toBeDefined(); + }); + + it('should render 2 navigation links per default', () => { + fixture.detectChanges(); + expect(htmlElem.querySelectorAll('a').length).toEqual(2); + }); + + it('should render no links if router states displayOnly', () => { + mockRouterState.state.params.displayOnly = true; + + fixture.detectChanges(); + expect(htmlElem.querySelectorAll('a').length).toEqual(0); + }); + + it('should tell from semantic route that we are on OV page', () => { + mockRouterState.state.semanticRoute = CONFIG_OVERVIEW_ROUTE; + component.isOverviewPage$ + .subscribe((isOv) => expect(isOv).toBe(true)) + .unsubscribe(); + }); + + it('should tell from semantic route that we are on config page', () => { + mockRouterState.state.semanticRoute = CONFIGURATOR_ROUTE; + component.isOverviewPage$ + .subscribe((isOv) => expect(isOv).toBe(false)) + .unsubscribe(); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.component.ts b/feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.component.ts new file mode 100644 index 00000000000..2e07a70d48b --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.component.ts @@ -0,0 +1,30 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + ConfiguratorRouter, + ConfiguratorRouterExtractorService, +} from '@spartacus/product-configurator/common'; +import {} from '@spartacus/storefront'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'cx-configurator-tab-bar', + templateUrl: './configurator-tab-bar.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorTabBarComponent { + routerData$: Observable< + ConfiguratorRouter.Data + > = this.configRouterExtractorService.extractRouterData(); + + isOverviewPage$: Observable = this.routerData$.pipe( + map( + (routerData) => + routerData.pageType === ConfiguratorRouter.PageType.OVERVIEW + ) + ); + + constructor( + protected configRouterExtractorService: ConfiguratorRouterExtractorService + ) {} +} diff --git a/feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.module.ts b/feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.module.ts new file mode 100644 index 00000000000..346e368ce41 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/tab-bar/configurator-tab-bar.module.ts @@ -0,0 +1,37 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { + CmsConfig, + I18nModule, + provideDefaultConfig, + UrlModule, +} from '@spartacus/core'; +import { ConfiguratorTabBarComponent } from './configurator-tab-bar.component'; + +@NgModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + NgSelectModule, + CommonModule, + I18nModule, + UrlModule, + RouterModule, + ], + providers: [ + provideDefaultConfig({ + cmsComponents: { + ConfiguratorTabBar: { + component: ConfiguratorTabBarComponent, + }, + }, + }), + ], + declarations: [ConfiguratorTabBarComponent], + exports: [ConfiguratorTabBarComponent], + entryComponents: [ConfiguratorTabBarComponent], +}) +export class ConfiguratorTabBarModule {} diff --git a/feature-libs/product-configurator/rulebased/components/tab-bar/index.ts b/feature-libs/product-configurator/rulebased/components/tab-bar/index.ts new file mode 100644 index 00000000000..e4d9bc89b2e --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/tab-bar/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-tab-bar.component'; +export * from './configurator-tab-bar.module'; diff --git a/feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.component.html b/feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.component.html new file mode 100644 index 00000000000..27ab9a8be29 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.component.html @@ -0,0 +1,4 @@ +
+ + {{ 'configurator.header.updateMessage' | cxTranslate }} +
diff --git a/feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.component.spec.ts b/feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.component.spec.ts new file mode 100644 index 00000000000..c192739e0ae --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.component.spec.ts @@ -0,0 +1,196 @@ +import { Component, Type } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterState } from '@angular/router'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { I18nTestingModule, RoutingService } from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import * as ConfigurationTestData from '../../shared/testing/configurator-test-data'; +import { MessageConfig } from '../config/message-config'; +import { ConfiguratorUpdateMessageComponent } from './configurator-update-message.component'; + +let routerStateObservable = null; +class MockRoutingService { + getRouterState(): Observable { + return routerStateObservable; + } +} + +const owner: CommonConfigurator.Owner = + ConfigurationTestData.productConfiguration.owner; + +let isConfigurationLoading = false; +let hasPendingChanges = false; +let waitingTime = 1000; + +class MockConfiguratorCommonsService { + hasPendingChanges(): Observable { + return of(hasPendingChanges); + } + isConfigurationLoading(): Observable { + return of(isConfigurationLoading); + } +} + +class MockMessageConfig { + productConfigurator = { + updateConfigurationMessage: { + waitingTime: waitingTime, + }, + }; +} +@Component({ + selector: 'cx-spinner', + template: '', +}) +class MockCxSpinnerComponent {} +describe('ConfigurationUpdateMessageComponent', () => { + let component: ConfiguratorUpdateMessageComponent; + let configuratorUtils: CommonConfiguratorUtilsService; + let fixture: ComponentFixture; + let htmlElem: HTMLElement; + + beforeEach( + waitForAsync(() => { + routerStateObservable = of(ConfigurationTestData.mockRouterState); + TestBed.configureTestingModule({ + imports: [I18nTestingModule, ReactiveFormsModule, NgSelectModule], + declarations: [ + ConfiguratorUpdateMessageComponent, + MockCxSpinnerComponent, + ], + providers: [ + { + provide: RoutingService, + useClass: MockRoutingService, + }, + + { + provide: MessageConfig, + useClass: MockMessageConfig, + }, + { + provide: ConfiguratorCommonsService, + useClass: MockConfiguratorCommonsService, + }, + ], + }); + }) + ); + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorUpdateMessageComponent); + htmlElem = fixture.nativeElement; + component = fixture.componentInstance; + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + configuratorUtils.setOwnerKey(owner); + }); + + it('should create component', () => { + expect(component).toBeDefined(); + }); + + it('should not show update banner if pending changes and loading is false', () => { + fixture.detectChanges(); + + //Should contain d-none class + expect(htmlElem.querySelectorAll('div.cx-update-msg.visible').length).toBe( + 0 + ); + }); + + it('should show update banner if pending changes is true', (done) => { + hasPendingChanges = true; + isConfigurationLoading = false; + fixture.detectChanges(); + + //Should be hidden first + expect(htmlElem.querySelectorAll('div.cx-update-msg.visible').length).toBe( + 0 + ); + + //Should appear after a bit + setTimeout(() => { + fixture.detectChanges(); + expect( + htmlElem.querySelectorAll('div.cx-update-msg.visible').length + ).toBe(1); + + expect(htmlElem.querySelectorAll('div').length).toBe(1); + done(); + }, 2000); + }); + + it('should show update banner if loading is true', (done) => { + hasPendingChanges = false; + isConfigurationLoading = true; + fixture.detectChanges(); + + //Should be hidden first + expect(htmlElem.querySelectorAll('div.cx-update-msg.visible').length).toBe( + 0 + ); + + //Should appear after a bit + setTimeout(() => { + fixture.detectChanges(); + expect( + htmlElem.querySelectorAll('div.cx-update-msg.visible').length + ).toBe(1); + + expect(htmlElem.querySelectorAll('div').length).toBe(1); + done(); + }, 2000); + }); + + it('should show update banner if loading and pending changes are true', (done) => { + hasPendingChanges = true; + isConfigurationLoading = true; + fixture.detectChanges(); + + //Should be hidden first + expect(htmlElem.querySelectorAll('div.cx-update-msg.visible').length).toBe( + 0 + ); + + //Should appear after a bit + setTimeout(() => { + fixture.detectChanges(); + expect( + htmlElem.querySelectorAll('div.cx-update-msg.visible').length + ).toBe(1); + + expect(htmlElem.querySelectorAll('div').length).toBe(1); + done(); + }, 2000); + }); + + it('should consider the configured timeout', (done) => { + hasPendingChanges = true; + isConfigurationLoading = true; + waitingTime = 100; + fixture.detectChanges(); + + //Should be hidden first + expect(htmlElem.querySelectorAll('div.cx-update-msg.visible').length).toBe( + 0 + ); + + //Should appear after a bit + setTimeout(() => { + fixture.detectChanges(); + expect( + htmlElem.querySelectorAll('div.cx-update-msg.visible').length + ).toBe(1); + + expect(htmlElem.querySelectorAll('div').length).toBe(1); + done(); + }, 2000); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.component.ts b/feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.component.ts new file mode 100644 index 00000000000..59755754799 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.component.ts @@ -0,0 +1,46 @@ +import { Component } from '@angular/core'; +import { ConfiguratorRouterExtractorService } from '@spartacus/product-configurator/common'; +import { Observable, of } from 'rxjs'; +import { delay, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; +import { ConfiguratorCommonsService } from '../../core/facade/configurator-commons.service'; +import { MessageConfig } from '../config/message-config'; + +@Component({ + selector: 'cx-configurator-update-message', + templateUrl: './configurator-update-message.component.html', +}) +export class ConfiguratorUpdateMessageComponent { + hasPendingChanges$: Observable< + boolean + > = this.configRouterExtractorService.extractRouterData().pipe( + switchMap((routerData) => + this.configuratorCommonsService + .hasPendingChanges(routerData.owner) + .pipe( + switchMap((hasPendingChanges) => + this.configuratorCommonsService + .isConfigurationLoading(routerData.owner) + .pipe(map((isLoading) => hasPendingChanges || isLoading)) + ) + ) + ), + distinctUntilChanged(), // avoid subsequent emissions of the same value from the source observable + switchMap( + (isLoading) => + isLoading + ? of(isLoading).pipe( + delay( + this.config?.productConfigurator?.updateConfigurationMessage + ?.waitingTime || 1000 + ) + ) // delay information if its loading + : of(isLoading) // inform disappears immediately if it's not loading anymore + ) + ); + + constructor( + protected configuratorCommonsService: ConfiguratorCommonsService, + protected configRouterExtractorService: ConfiguratorRouterExtractorService, + protected config: MessageConfig + ) {} +} diff --git a/feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.module.ts b/feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.module.ts new file mode 100644 index 00000000000..283faed692e --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/update-message/configurator-update-message.module.ts @@ -0,0 +1,31 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { + CmsConfig, + Config, + I18nModule, + provideDefaultConfig, +} from '@spartacus/core'; +import { SpinnerModule } from '@spartacus/storefront'; +import { DefaultMessageConfig } from '../config/default-message-config'; +import { MessageConfig } from '../config/message-config'; +import { ConfiguratorUpdateMessageComponent } from './configurator-update-message.component'; + +@NgModule({ + imports: [CommonModule, SpinnerModule, I18nModule], + providers: [ + provideDefaultConfig({ + cmsComponents: { + ConfiguratorUpdateMessage: { + component: ConfiguratorUpdateMessageComponent, + }, + }, + }), + provideDefaultConfig(DefaultMessageConfig), + { provide: MessageConfig, useExisting: Config }, + ], + declarations: [ConfiguratorUpdateMessageComponent], + exports: [ConfiguratorUpdateMessageComponent], + entryComponents: [ConfiguratorUpdateMessageComponent], +}) +export class ConfiguratorUpdateMessageModule {} diff --git a/feature-libs/product-configurator/rulebased/components/update-message/index.ts b/feature-libs/product-configurator/rulebased/components/update-message/index.ts new file mode 100644 index 00000000000..899df462c5d --- /dev/null +++ b/feature-libs/product-configurator/rulebased/components/update-message/index.ts @@ -0,0 +1,2 @@ +export * from './configurator-update-message.component'; +export * from './configurator-update-message.module'; diff --git a/feature-libs/product-configurator/rulebased/core/connectors/index.ts b/feature-libs/product-configurator/rulebased/core/connectors/index.ts new file mode 100644 index 00000000000..f8a6d894713 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/connectors/index.ts @@ -0,0 +1,2 @@ +export * from './rulebased-configurator.adapter'; +export * from './rulebased-configurator.connector'; diff --git a/feature-libs/product-configurator/rulebased/core/connectors/rulebased-configurator.adapter.ts b/feature-libs/product-configurator/rulebased/core/connectors/rulebased-configurator.adapter.ts new file mode 100644 index 00000000000..d34aa3f841f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/connectors/rulebased-configurator.adapter.ts @@ -0,0 +1,103 @@ +import { CartModification } from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { Configurator } from '../model/configurator.model'; + +export abstract class RulebasedConfiguratorAdapter { + /** + * Abstract method used to create a configuration + * + * @param productCode Root product code + */ + abstract createConfiguration( + owner: CommonConfigurator.Owner + ): Observable; + + /** + * Abstract method to read a configuration. + * If groupId is filled only the attributes of the requested group are returned. + * For other groups the attributes list will be empty. + * + * @param configId configuration id + * @param groupId group id + */ + abstract readConfiguration( + configId: string, + groupId: string, + configurationOwner: CommonConfigurator.Owner + ): Observable; + + /** + * Abstract method to update a configuration + * + * @param configuration updated configuration object + */ + abstract updateConfiguration( + configuration: Configurator.Configuration + ): Observable; + + /** + + * Abstract method to add a configuration to cart. + * + * @param parameters add to cart parameters object + */ + abstract addToCart( + parameters: Configurator.AddToCartParameters + ): Observable; + + /** + * Abstract method to read a configuration for a cart entry + * + * @param parameters read from cart entry parameters object + */ + abstract readConfigurationForCartEntry( + parameters: CommonConfigurator.ReadConfigurationFromCartEntryParameters + ): Observable; + + /** + * Abstract method to update a configuration attached to a cart entry + * + * @param parameters update cart entry configuration parameters object + */ + abstract updateConfigurationForCartEntry( + parameters: Configurator.UpdateConfigurationForCartEntryParameters + ): Observable; + + /** + * Abstract method to read a configuration for an order entry + * + * @param parameters Contains attributes that we need to read a configuration attached to an order entry + * @returns {Observable} Configuration with only the overview aspect provided + */ + abstract readConfigurationForOrderEntry( + parameters: CommonConfigurator.ReadConfigurationFromOrderEntryParameters + ): Observable; + + /** + * Abstract method to read a configuration price + * + * @param configId configuration id + */ + abstract readPriceSummary( + configuration: Configurator.Configuration + ): Observable; + + /** + * Abstract method to get configuration overview + * + * @param configId configuration id + * @param owner configuration owner + */ + abstract getConfigurationOverview( + configId: string + ): Observable; + + /** + * Abstract method to get configuration overview + * + * @param configId configuration id + * @param owner configuration owner + */ + abstract getConfiguratorType(): string; +} diff --git a/feature-libs/product-configurator/rulebased/core/connectors/rulebased-configurator.connector.spec.ts b/feature-libs/product-configurator/rulebased/core/connectors/rulebased-configurator.connector.spec.ts new file mode 100644 index 00000000000..013a5019276 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/connectors/rulebased-configurator.connector.spec.ts @@ -0,0 +1,255 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CartModification } from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { of } from 'rxjs'; +import { Configurator } from '../model/configurator.model'; +import { RulebasedConfiguratorAdapter } from './rulebased-configurator.adapter'; +import { RulebasedConfiguratorConnector } from './rulebased-configurator.connector'; + +import createSpy = jasmine.createSpy; + +const PRODUCT_CODE = 'CONF_LAPTOP'; +const CONFIG_ID = '1234-56-7890'; +const USER_ID = 'theUser'; +const CART_ID = '98876'; +const CONFIGURATOR_TYPE = 'cpqconfig'; + +const productConfiguration: Configurator.Configuration = { + configId: CONFIG_ID, + productCode: PRODUCT_CODE, + owner: { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.PRODUCT, + configuratorType: CONFIGURATOR_TYPE, + }, +}; + +const readFromCartEntryParameters: CommonConfigurator.ReadConfigurationFromCartEntryParameters = { + userId: USER_ID, + cartId: CART_ID, + owner: productConfiguration.owner, +}; + +const readFromOrderEntryParameters: CommonConfigurator.ReadConfigurationFromOrderEntryParameters = { + userId: USER_ID, + orderId: CART_ID, + owner: productConfiguration.owner, +}; + +const updateFromCartEntryParameters: Configurator.UpdateConfigurationForCartEntryParameters = { + userId: USER_ID, + cartId: CART_ID, + configuration: productConfiguration, +}; + +const cartModification: CartModification = {}; + +class MockRulebasedConfiguratorAdapter implements RulebasedConfiguratorAdapter { + readConfigurationForCartEntry = createSpy().and.callFake(() => + of(productConfiguration) + ); + readConfigurationForOrderEntry = createSpy().and.callFake(() => + of(productConfiguration) + ); + updateConfigurationForCartEntry = createSpy().and.callFake(() => + of(cartModification) + ); + getConfigurationOverview = createSpy().and.callFake((configId: string) => + of('getConfigurationOverview' + configId) + ); + + readPriceSummary = createSpy().and.callFake((configId) => + of('readPriceSummary' + configId) + ); + + readConfiguration = createSpy().and.callFake((configId) => + of('readConfiguration' + configId) + ); + + updateConfiguration = createSpy().and.callFake((configuration) => + of('updateConfiguration' + configuration.configId) + ); + + createConfiguration = createSpy().and.callFake((owner) => + of('createConfiguration' + owner) + ); + + addToCart = createSpy().and.callFake((configId) => + of('addToCart' + configId) + ); + getConfiguratorType(): string { + return CONFIGURATOR_TYPE; + } +} + +describe('RulebasedConfiguratorConnector', () => { + let service: RulebasedConfiguratorConnector; + let configuratorUtils: CommonConfiguratorUtilsService; + let adapter: RulebasedConfiguratorAdapter[]; + + const GROUP_ID = 'GROUP1'; + + const QUANTITY = 1; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: RulebasedConfiguratorConnector.CONFIGURATOR_ADAPTER_LIST, + useClass: MockRulebasedConfiguratorAdapter, + multi: true, + }, + { + provide: RulebasedConfiguratorConnector, + useClass: RulebasedConfiguratorConnector, + }, + ], + }); + service = TestBed.inject( + RulebasedConfiguratorConnector as Type + ); + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + adapter = TestBed.inject( + RulebasedConfiguratorConnector.CONFIGURATOR_ADAPTER_LIST + ); + configuratorUtils.setOwnerKey(productConfiguration.owner); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call adapter on createConfiguration', () => { + let result; + service + .createConfiguration(productConfiguration.owner) + .subscribe((res) => (result = res)); + expect(result).toBe('createConfiguration' + productConfiguration.owner); + + expect(adapter[0].createConfiguration).toHaveBeenCalledWith( + productConfiguration.owner + ); + }); + + it('should throw an error in case no adapter present for configurator type', () => { + expect(function () { + const ownerForUnknownConfigurator: CommonConfigurator.Owner = { + configuratorType: 'unknown', + type: CommonConfigurator.OwnerType.PRODUCT, + id: PRODUCT_CODE, + }; + service.createConfiguration(ownerForUnknownConfigurator); + }).toThrow(); + }); + + it('should not throw an error in case an adapter is present for owners configurator type', () => { + expect(function () { + const ownerForUnknownConfigurator: CommonConfigurator.Owner = { + configuratorType: CONFIGURATOR_TYPE, + type: CommonConfigurator.OwnerType.PRODUCT, + id: PRODUCT_CODE, + }; + service.createConfiguration(ownerForUnknownConfigurator); + }).toBeDefined(); + }); + + it('should call adapter on readConfigurationForCartEntry', () => { + service + .readConfigurationForCartEntry(readFromCartEntryParameters) + .subscribe((configuration) => + expect(configuration).toBe(productConfiguration) + ); + expect(adapter[0].readConfigurationForCartEntry).toHaveBeenCalledWith( + readFromCartEntryParameters + ); + }); + + it('should call adapter on updateConfigurationForCartEntry', () => { + service + .updateConfigurationForCartEntry(updateFromCartEntryParameters) + .subscribe((result) => expect(result).toBe(cartModification)); + expect(adapter[0].updateConfigurationForCartEntry).toHaveBeenCalledWith( + updateFromCartEntryParameters + ); + }); + + it('should call adapter on readConfigurationForOrderEntry', () => { + service + .readConfigurationForOrderEntry(readFromOrderEntryParameters) + .subscribe((configuration) => + expect(configuration).toBe(productConfiguration) + ); + expect(adapter[0].readConfigurationForOrderEntry).toHaveBeenCalledWith( + readFromOrderEntryParameters + ); + }); + + it('should call adapter on readConfiguration', () => { + let result; + service + .readConfiguration(CONFIG_ID, GROUP_ID, productConfiguration.owner) + .subscribe((res) => (result = res)); + expect(result).toBe('readConfiguration' + CONFIG_ID); + expect(adapter[0].readConfiguration).toHaveBeenCalledWith( + CONFIG_ID, + GROUP_ID, + productConfiguration.owner + ); + }); + + it('should call adapter on updateConfiguration', () => { + let result; + service + .updateConfiguration(productConfiguration) + .subscribe((res) => (result = res)); + expect(result).toBe('updateConfiguration' + CONFIG_ID); + expect(adapter[0].updateConfiguration).toHaveBeenCalledWith( + productConfiguration + ); + }); + + it('should call adapter on readConfigurationPrice', () => { + let result; + service + .readPriceSummary(productConfiguration) + .subscribe((res) => (result = res)); + expect(result).toBe('readPriceSummary' + productConfiguration); + expect(adapter[0].readPriceSummary).toHaveBeenCalledWith( + productConfiguration + ); + }); + + it('should call adapter on getConfigurationOverview', () => { + let result; + service + .getConfigurationOverview(productConfiguration) + .subscribe((res) => (result = res)); + expect(result).toBe( + 'getConfigurationOverview' + productConfiguration.configId + ); + expect(adapter[0].getConfigurationOverview).toHaveBeenCalledWith( + productConfiguration.configId + ); + }); + + it('should call adapter on addToCart', () => { + const parameters: Configurator.AddToCartParameters = { + userId: USER_ID, + cartId: CART_ID, + productCode: PRODUCT_CODE, + quantity: QUANTITY, + configId: CONFIG_ID, + owner: productConfiguration.owner, + }; + let result; + service.addToCart(parameters).subscribe((res) => (result = res)); + expect(adapter[0].addToCart).toHaveBeenCalledWith(parameters); + expect(result).toBe('addToCart' + parameters); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/core/connectors/rulebased-configurator.connector.ts b/feature-libs/product-configurator/rulebased/core/connectors/rulebased-configurator.connector.ts new file mode 100644 index 00000000000..05782efb88a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/connectors/rulebased-configurator.connector.ts @@ -0,0 +1,108 @@ +import { Inject, Injectable, InjectionToken } from '@angular/core'; +import { CartModification } from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { Configurator } from '../model/configurator.model'; +import { RulebasedConfiguratorAdapter } from './rulebased-configurator.adapter'; + +//Not provided in root, as this would break lazy loading +@Injectable() +export class RulebasedConfiguratorConnector { + static CONFIGURATOR_ADAPTER_LIST = new InjectionToken< + RulebasedConfiguratorAdapter[] + >('ConfiguratorAdapterList'); + + constructor( + @Inject(RulebasedConfiguratorConnector.CONFIGURATOR_ADAPTER_LIST) + protected adapters: RulebasedConfiguratorAdapter[], + protected configUtilsService: CommonConfiguratorUtilsService + ) {} + + createConfiguration( + owner: CommonConfigurator.Owner + ): Observable { + return this.getAdapter(owner.configuratorType).createConfiguration(owner); + } + + readConfiguration( + configId: string, + groupId: string, + configurationOwner: CommonConfigurator.Owner + ): Observable { + return this.getAdapter( + configurationOwner.configuratorType + ).readConfiguration(configId, groupId, configurationOwner); + } + + updateConfiguration( + configuration: Configurator.Configuration + ): Observable { + return this.getAdapter( + configuration.owner.configuratorType + ).updateConfiguration(configuration); + } + + addToCart( + parameters: Configurator.AddToCartParameters + ): Observable { + return this.getAdapter(parameters.owner.configuratorType).addToCart( + parameters + ); + } + + readConfigurationForCartEntry( + parameters: CommonConfigurator.ReadConfigurationFromCartEntryParameters + ): Observable { + return this.getAdapter( + parameters.owner.configuratorType + ).readConfigurationForCartEntry(parameters); + } + + updateConfigurationForCartEntry( + parameters: Configurator.UpdateConfigurationForCartEntryParameters + ): Observable { + return this.getAdapter( + parameters.configuration.owner.configuratorType + ).updateConfigurationForCartEntry(parameters); + } + + readConfigurationForOrderEntry( + parameters: CommonConfigurator.ReadConfigurationFromOrderEntryParameters + ): Observable { + return this.getAdapter( + parameters.owner.configuratorType + ).readConfigurationForOrderEntry(parameters); + } + + readPriceSummary( + configuration: Configurator.Configuration + ): Observable { + return this.getAdapter( + configuration.owner.configuratorType + ).readPriceSummary(configuration); + } + + getConfigurationOverview( + configuration: Configurator.Configuration + ): Observable { + return this.getAdapter( + configuration.owner.configuratorType + ).getConfigurationOverview(configuration.configId); + } + + protected getAdapter(configuratorType: string): RulebasedConfiguratorAdapter { + const adapterResult = this.adapters.find( + (adapter) => adapter.getConfiguratorType() === configuratorType + ); + if (adapterResult) { + return adapterResult; + } else { + throw new Error( + 'No adapter found for configurator type: ' + configuratorType + ); + } + } +} diff --git a/feature-libs/product-configurator/rulebased/core/facade/configurator-cart.service.spec.ts b/feature-libs/product-configurator/rulebased/core/facade/configurator-cart.service.spec.ts new file mode 100644 index 00000000000..7eb9f2e1ba8 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/facade/configurator-cart.service.spec.ts @@ -0,0 +1,365 @@ +import { Type } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import * as ngrxStore from '@ngrx/store'; +import { Store, StoreModule } from '@ngrx/store'; +import { + ActiveCartService, + Cart, + CheckoutService, + OCC_USER_ID_ANONYMOUS, + OCC_USER_ID_CURRENT, + StateUtils, +} from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, + OrderEntryStatus, +} from '@spartacus/product-configurator/common'; +import { cold } from 'jasmine-marbles'; +import { Observable, of } from 'rxjs'; +import { Configurator } from '../model/configurator.model'; +import { ConfiguratorActions } from '../state/actions/index'; +import { + CONFIGURATOR_FEATURE, + StateWithConfigurator, +} from '../state/configurator-state'; +import { getConfiguratorReducers } from '../state/reducers/index'; +import { ConfiguratorCartService } from './configurator-cart.service'; + +let OWNER_CART_ENTRY: CommonConfigurator.Owner = {}; +let OWNER_ORDER_ENTRY: CommonConfigurator.Owner = {}; +let OWNER_PRODUCT: CommonConfigurator.Owner = {}; +const CART_CODE = '0000009336'; +const CART_GUID = 'e767605d-7336-48fd-b156-ad50d004ca10'; +const ORDER_ID = '0000011'; +const ORDER_ENTRY_NUMBER = 2; +const PRODUCT_CODE = 'CONF_LAPTOP'; +const CONFIG_ID = '1234-56-7890'; + +const cart: Cart = { + code: CART_CODE, + guid: CART_GUID, + user: { uid: OCC_USER_ID_ANONYMOUS }, + entries: [ + { + statusSummaryList: [ + { status: OrderEntryStatus.Success, numberOfIssues: 1 }, + ], + }, + { + statusSummaryList: [ + { status: OrderEntryStatus.Error, numberOfIssues: 0 }, + ], + }, + { + statusSummaryList: [{ status: OrderEntryStatus.Info, numberOfIssues: 3 }], + }, + ], +}; + +const productConfiguration: Configurator.Configuration = { + configId: CONFIG_ID, + owner: OWNER_CART_ENTRY, +}; + +const cartState: StateUtils.ProcessesLoaderState = { + value: cart, +}; +let cartStateObs = null; +let isStableObs = null; +let checkoutLoadingObs = null; +class MockActiveCartService { + requireLoadedCart(): Observable> { + return cartStateObs; + } + isStable(): Observable { + return isStableObs; + } +} + +class MockCheckoutService { + isLoading(): Observable { + return checkoutLoadingObs; + } +} + +describe('ConfiguratorCartService', () => { + let serviceUnderTest: ConfiguratorCartService; + let store: Store; + let configuratorUtils: CommonConfiguratorUtilsService; + + beforeEach( + waitForAsync(() => { + cartStateObs = of(cartState); + isStableObs = of(true); + checkoutLoadingObs = of(true); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature(CONFIGURATOR_FEATURE, getConfiguratorReducers), + ], + providers: [ + ConfiguratorCartService, + + { + provide: ActiveCartService, + useClass: MockActiveCartService, + }, + { + provide: CheckoutService, + useClass: MockCheckoutService, + }, + ], + }).compileComponents(); + }) + ); + beforeEach(() => { + serviceUnderTest = TestBed.inject( + ConfiguratorCartService as Type + ); + store = TestBed.inject(Store as Type>); + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + OWNER_CART_ENTRY = { + id: '3', + type: CommonConfigurator.OwnerType.CART_ENTRY, + }; + OWNER_ORDER_ENTRY = { + id: configuratorUtils.getComposedOwnerId(ORDER_ID, ORDER_ENTRY_NUMBER), + type: CommonConfigurator.OwnerType.ORDER_ENTRY, + }; + OWNER_PRODUCT = { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.PRODUCT, + }; + }); + + it('should create service', () => { + expect(serviceUnderTest).toBeDefined(); + }); + + describe('readConfigurationForCartEntry', () => { + it('should not dispatch ReadCartEntryConfiguration action in case configuration is present', () => { + const productConfigurationLoaderState: StateUtils.LoaderState = { + value: productConfiguration, + }; + + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(productConfigurationLoaderState) + ); + spyOn(store, 'dispatch').and.callThrough(); + + serviceUnderTest + .readConfigurationForCartEntry(OWNER_CART_ENTRY) + .subscribe() + .unsubscribe(); + + expect(store.dispatch).toHaveBeenCalledTimes(0); + }); + + it('should dispatch ReadCartEntryConfiguration action in case configuration is not present so far', () => { + const params: CommonConfigurator.ReadConfigurationFromCartEntryParameters = { + owner: OWNER_CART_ENTRY, + cartEntryNumber: OWNER_CART_ENTRY.id, + cartId: CART_GUID, + userId: OCC_USER_ID_ANONYMOUS, + }; + const productConfigurationLoaderState: StateUtils.LoaderState = { + value: { configId: '' }, + }; + + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(productConfigurationLoaderState) + ); + spyOn(store, 'dispatch').and.callThrough(); + + serviceUnderTest + .readConfigurationForCartEntry(OWNER_CART_ENTRY) + .subscribe() + .unsubscribe(); + + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorActions.ReadCartEntryConfiguration(params) + ); + }); + + it('should only proceed when cart is ready', () => { + isStableObs = cold('x-xx-y', { + x: false, + y: true, + }); + checkoutLoadingObs = cold('a', { + a: false, + }); + + const productConfigurationLoaderState: StateUtils.LoaderState = { + value: { configId: '' }, + }; + + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(productConfigurationLoaderState) + ); + + expect( + serviceUnderTest.readConfigurationForCartEntry(OWNER_CART_ENTRY) + ).toBeObservable(cold('-----|', {})); + }); + + it('should only proceed when checkout data has been loaded', () => { + isStableObs = cold('a', { + a: true, + }); + checkoutLoadingObs = cold('xxy', { + x: true, + y: false, + }); + + const productConfigurationLoaderState: StateUtils.LoaderState = { + value: { configId: '' }, + }; + + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(productConfigurationLoaderState) + ); + + expect( + serviceUnderTest.readConfigurationForCartEntry(OWNER_CART_ENTRY) + ).toBeObservable(cold('--|')); + }); + }); + + describe('readConfigurationForOrderEntry', () => { + it('should not dispatch ReadOrderEntryConfiguration action in case configuration is present', () => { + const productConfigurationLoaderState: StateUtils.LoaderState = { + value: productConfiguration, + }; + + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(productConfigurationLoaderState) + ); + spyOn(store, 'dispatch').and.callThrough(); + + serviceUnderTest + .readConfigurationForOrderEntry(OWNER_ORDER_ENTRY) + .subscribe() + .unsubscribe(); + + expect(store.dispatch).toHaveBeenCalledTimes(0); + }); + + it('should dispatch ReadOrderEntryConfiguration action in case configuration is not present so far', () => { + const params: CommonConfigurator.ReadConfigurationFromOrderEntryParameters = { + owner: OWNER_ORDER_ENTRY, + orderEntryNumber: '' + ORDER_ENTRY_NUMBER, + orderId: ORDER_ID, + userId: OCC_USER_ID_CURRENT, + }; + const productConfigurationLoaderState: StateUtils.LoaderState = { + value: { configId: '' }, + }; + + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(productConfigurationLoaderState) + ); + spyOn(store, 'dispatch').and.callThrough(); + serviceUnderTest + .readConfigurationForOrderEntry(OWNER_ORDER_ENTRY) + .subscribe() + .unsubscribe(); + + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorActions.ReadOrderEntryConfiguration(params) + ); + }); + }); + describe('addToCart', () => { + it('should get cart, create addToCartParameters and call addToCart action', () => { + const addToCartParams: Configurator.AddToCartParameters = { + cartId: CART_GUID, + userId: OCC_USER_ID_ANONYMOUS, + productCode: PRODUCT_CODE, + quantity: 1, + configId: CONFIG_ID, + owner: OWNER_PRODUCT, + }; + + spyOn(store, 'dispatch').and.callThrough(); + + serviceUnderTest.addToCart(PRODUCT_CODE, CONFIG_ID, OWNER_PRODUCT); + + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorActions.AddToCart(addToCartParams) + ); + }); + }); + describe('updateCartEntry', () => { + it('should create updateParameters and call updateCartEntry action', () => { + const params: Configurator.UpdateConfigurationForCartEntryParameters = { + cartId: CART_GUID, + userId: OCC_USER_ID_ANONYMOUS, + cartEntryNumber: productConfiguration.owner.id, + configuration: productConfiguration, + }; + + spyOn(store, 'dispatch').and.callThrough(); + const obs = cold('|'); + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => obs); + serviceUnderTest.updateCartEntry(productConfiguration); + + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorActions.UpdateCartEntry(params) + ); + }); + }); + + describe('activeCartHasIssues', () => { + it('should tell that cart has no issues in case status summary contain no errors for all cart entries', () => { + cartStateObs = cold('xx', { + x: cartState, + }); + expect(serviceUnderTest.activeCartHasIssues()).toBeObservable( + cold('aa', { a: false }) + ); + }); + + it('should tell that cart has issues in case status summary contain at least one entry with an error', () => { + const cartIssues: Cart = { + ...cart, + entries: [ + { + statusSummaryList: [ + { status: OrderEntryStatus.Error, numberOfIssues: 1 }, + ], + }, + ], + }; + const cartStateIssues: StateUtils.ProcessesLoaderState = { + value: cartIssues, + }; + cartStateObs = cold('xy', { + x: cartState, + y: cartStateIssues, + }); + expect(serviceUnderTest.activeCartHasIssues()).toBeObservable( + cold('ab', { a: false, b: true }) + ); + }); + it('should handle cart with no entries', () => { + const cartEmpty: Cart = { + ...cart, + entries: undefined, + }; + const cartStateEmpty: StateUtils.ProcessesLoaderState = { + value: cartEmpty, + }; + cartStateObs = cold('xy', { + x: cartState, + y: cartStateEmpty, + }); + expect(serviceUnderTest.activeCartHasIssues()).toBeObservable( + cold('aa', { a: false }) + ); + }); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/core/facade/configurator-cart.service.ts b/feature-libs/product-configurator/rulebased/core/facade/configurator-cart.service.ts new file mode 100644 index 00000000000..c093ce60ac6 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/facade/configurator-cart.service.ts @@ -0,0 +1,212 @@ +import { Injectable } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { + ActiveCartService, + CheckoutService, + OCC_USER_ID_CURRENT, + StateUtils, + StateWithMultiCart, +} from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { delayWhen, filter, map, take, tap } from 'rxjs/operators'; +import { Configurator } from '../model/configurator.model'; +import { ConfiguratorActions } from '../state/actions/index'; +import { StateWithConfigurator } from '../state/configurator-state'; +import { ConfiguratorSelectors } from '../state/selectors/index'; + +@Injectable({ providedIn: 'root' }) +export class ConfiguratorCartService { + constructor( + protected cartStore: Store, + protected store: Store, + protected activeCartService: ActiveCartService, + protected commonConfigUtilsService: CommonConfiguratorUtilsService, + protected checkoutService: CheckoutService + ) {} + + /** + * Reads a configuratiom that is attached to a cart entry, dispatching the respective action + * @param owner Configuration owner + * @returns Observable of product configurations + */ + readConfigurationForCartEntry( + owner: CommonConfigurator.Owner + ): Observable { + return this.store.pipe( + select( + ConfiguratorSelectors.getConfigurationProcessLoaderStateFactory( + owner.key + ) + ), + //needed as we cannot read the cart in general and for the OV + //in parallel, this can lead to cache issues with promotions + delayWhen(() => + this.activeCartService.isStable().pipe(filter((stable) => stable)) + ), + delayWhen(() => + this.checkoutService.isLoading().pipe(filter((loading) => !loading)) + ), + tap((configurationState) => { + if (this.configurationNeedsReading(configurationState)) { + this.activeCartService + .requireLoadedCart() + .pipe(take(1)) + .subscribe((cartState) => { + const readFromCartEntryParameters: CommonConfigurator.ReadConfigurationFromCartEntryParameters = { + userId: this.commonConfigUtilsService.getUserId( + cartState.value + ), + cartId: this.commonConfigUtilsService.getCartId( + cartState.value + ), + cartEntryNumber: owner.id, + owner: owner, + }; + this.store.dispatch( + new ConfiguratorActions.ReadCartEntryConfiguration( + readFromCartEntryParameters + ) + ); + }); + } + }), + filter((configurationState) => + this.isConfigurationCreated(configurationState.value) + ), + map((configurationState) => configurationState.value) + ); + } + + /** + * Reads a configuratiom that is attached to an order entry, dispatching the respective action + * @param owner Configuration owner + * @returns Observable of product configurations + */ + readConfigurationForOrderEntry( + owner: CommonConfigurator.Owner + ): Observable { + return this.store.pipe( + select( + ConfiguratorSelectors.getConfigurationProcessLoaderStateFactory( + owner.key + ) + ), + tap((configurationState) => { + if (this.configurationNeedsReading(configurationState)) { + const ownerIdParts = this.commonConfigUtilsService.decomposeOwnerId( + owner.id + ); + const readFromOrderEntryParameters: CommonConfigurator.ReadConfigurationFromOrderEntryParameters = { + userId: OCC_USER_ID_CURRENT, + orderId: ownerIdParts.documentId, + orderEntryNumber: ownerIdParts.entryNumber, + owner: owner, + }; + this.store.dispatch( + new ConfiguratorActions.ReadOrderEntryConfiguration( + readFromOrderEntryParameters + ) + ); + } + }), + filter((configurationState) => + this.isConfigurationCreated(configurationState.value) + ), + map((configurationState) => configurationState.value) + ); + } + + /** + * Adds a configuration to the cart, specified by the product code, a configuration ID and configuration owner key. + * + * @param productCode - Product code + * @param configId - Configuration ID + * @param owner Configuration owner + */ + addToCart( + productCode: string, + configId: string, + owner: CommonConfigurator.Owner + ): void { + this.activeCartService + .requireLoadedCart() + .pipe(take(1)) + .subscribe((cartState) => { + const addToCartParameters: Configurator.AddToCartParameters = { + userId: this.commonConfigUtilsService.getUserId(cartState.value), + cartId: this.commonConfigUtilsService.getCartId(cartState.value), + productCode: productCode, + quantity: 1, + configId: configId, + owner: owner, + }; + this.store.dispatch( + new ConfiguratorActions.AddToCart(addToCartParameters) + ); + }); + } + + /** + * Updates a cart entry, specified by the configuration. + * The cart entry number for the entry that owns the configuration can be told + * from the configuration's owner ID + * + * @param configuration - Configuration + */ + updateCartEntry(configuration: Configurator.Configuration): void { + this.activeCartService + .requireLoadedCart() + .pipe(take(1)) + .subscribe((cartState) => { + const cartId = this.commonConfigUtilsService.getCartId(cartState.value); + const parameters: Configurator.UpdateConfigurationForCartEntryParameters = { + userId: this.commonConfigUtilsService.getUserId(cartState.value), + cartId: cartId, + cartEntryNumber: configuration.owner.id, + configuration: configuration, + }; + + this.store.dispatch( + new ConfiguratorActions.UpdateCartEntry(parameters) + ); + }); + } + /** + * Can be used to check if the active cart has any product configuration issues. + * @returns True if and only if there is at least one cart entry with product configuration issues + */ + activeCartHasIssues(): Observable { + return this.activeCartService.requireLoadedCart().pipe( + map((cartState) => cartState.value.entries), + map((entries) => + entries + ? entries.filter((entry) => + this.commonConfigUtilsService.getNumberOfIssues(entry) + ) + : [] + ), + map((entries) => entries.length > 0) + ); + } + + protected isConfigurationCreated( + configuration: Configurator.Configuration + ): boolean { + const configId: String = configuration?.configId; + return configId !== undefined && configId.length !== 0; + } + + protected configurationNeedsReading( + configurationState: StateUtils.LoaderState + ): boolean { + return ( + !this.isConfigurationCreated(configurationState.value) && + !configurationState.loading && + !configurationState.error + ); + } +} diff --git a/feature-libs/product-configurator/rulebased/core/facade/configurator-commons.service.spec.ts b/feature-libs/product-configurator/rulebased/core/facade/configurator-commons.service.spec.ts new file mode 100644 index 00000000000..f5ba023e272 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/facade/configurator-commons.service.spec.ts @@ -0,0 +1,516 @@ +import { Type } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import * as ngrxStore from '@ngrx/store'; +import { Store, StoreModule } from '@ngrx/store'; +import { ActiveCartService, Cart, StateUtils } from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { cold } from 'jasmine-marbles'; +import { Observable, of } from 'rxjs'; +import { productConfigurationWithConflicts } from '../../shared/testing/configurator-test-data'; +import { Configurator } from '../model/configurator.model'; +import { ConfiguratorActions } from '../state/actions/index'; +import { + ConfiguratorState, + CONFIGURATOR_FEATURE, + StateWithConfigurator, +} from '../state/configurator-state'; +import { getConfiguratorReducers } from '../state/reducers/index'; +import { ConfiguratorCartService } from './configurator-cart.service'; +import { ConfiguratorCommonsService } from './configurator-commons.service'; +import { ConfiguratorUtilsService } from './utils'; + +const PRODUCT_CODE = 'CONF_LAPTOP'; +let OWNER_PRODUCT: CommonConfigurator.Owner = {}; +let OWNER_CART_ENTRY: CommonConfigurator.Owner = {}; +let OWNER_ORDER_ENTRY: CommonConfigurator.Owner = {}; + +const CONFIG_ID = '1234-56-7890'; +const GROUP_ID_1 = '123ab'; +const GROUP_ID_2 = '1234-56-7892'; + +const GROUP_NAME = 'Software'; +const GROUP_NAME_2 = 'Hardware'; + +const ATTRIBUTE_NAME_1 = 'Attribute_1'; +const ATTRIBUTE_NAME_2 = 'Attribute_DropDown'; + +const ORDER_ID = '0000011'; +const ORDER_ENTRY_NUMBER = 2; + +const group1: Configurator.Group = { + id: GROUP_ID_1, + name: GROUP_NAME, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + attributes: [ + { + name: ATTRIBUTE_NAME_1, + uiType: Configurator.UiType.STRING, + userInput: 'input', + }, + { + name: ATTRIBUTE_NAME_2, + uiType: Configurator.UiType.DROPDOWN, + userInput: null, + }, + ], +}; + +const group2: Configurator.Group = { + id: GROUP_ID_2, + name: GROUP_NAME_2, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, +}; + +let productConfiguration: Configurator.Configuration = { + configId: CONFIG_ID, +}; + +const productConfigurationChanged: Configurator.Configuration = { + configId: CONFIG_ID, +}; + +const configurationState: ConfiguratorState = { + configurations: { entities: {} }, +}; + +let configCartObservable; +let configOrderObservable; +let isStableObservable; +let cartObs; + +class MockActiveCartService { + isStable(): Observable { + return isStableObservable; + } + getActive(): Observable { + return cartObs; + } +} + +class MockconfiguratorUtilsService { + createConfigurationExtract(): Configurator.Configuration { + return productConfiguration; + } + isConfigurationCreated(configuration: Configurator.Configuration): boolean { + const configId: String = configuration?.configId; + return configId !== undefined && configId.length !== 0; + } +} + +class MockConfiguratorCartService { + readConfigurationForCartEntry() { + return configCartObservable; + } + readConfigurationForOrderEntry() { + return configOrderObservable; + } +} + +function callGetOrCreate( + serviceUnderTest: ConfiguratorCommonsService, + owner: CommonConfigurator.Owner +) { + const productConfigurationLoaderState: StateUtils.LoaderState = { + value: productConfiguration, + }; + const productConfigurationLoaderStateChanged: StateUtils.LoaderState = { + value: productConfigurationChanged, + }; + const obs = cold('x-y', { + x: productConfigurationLoaderState, + y: productConfigurationLoaderStateChanged, + }); + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => obs); + const configurationObs = serviceUnderTest.getOrCreateConfiguration(owner); + return configurationObs; +} + +describe('ConfiguratorCommonsService', () => { + let serviceUnderTest: ConfiguratorCommonsService; + let configuratorUtils: CommonConfiguratorUtilsService; + let configuratorUtilsService: ConfiguratorUtilsService; + let store: Store; + let configuratorCartService: ConfiguratorCartService; + configOrderObservable = of(productConfiguration); + configCartObservable = of(productConfiguration); + isStableObservable = of(true); + const cart: Cart = {}; + cartObs = of(cart); + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature(CONFIGURATOR_FEATURE, getConfiguratorReducers), + ], + providers: [ + ConfiguratorCommonsService, + { + provide: ConfiguratorCartService, + useClass: MockConfiguratorCartService, + }, + { + provide: ActiveCartService, + useClass: MockActiveCartService, + }, + { + provide: ConfiguratorUtilsService, + useClass: MockconfiguratorUtilsService, + }, + ], + }); + }) + ); + beforeEach(() => { + configOrderObservable = of(productConfiguration); + configCartObservable = of(productConfiguration); + isStableObservable = of(true); + + serviceUnderTest = TestBed.inject( + ConfiguratorCommonsService as Type + ); + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + configuratorUtilsService = TestBed.inject( + ConfiguratorUtilsService as Type + ); + + OWNER_PRODUCT = { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.PRODUCT, + }; + + OWNER_CART_ENTRY = { + id: '3', + type: CommonConfigurator.OwnerType.CART_ENTRY, + }; + + OWNER_ORDER_ENTRY = { + id: configuratorUtils.getComposedOwnerId(ORDER_ID, ORDER_ENTRY_NUMBER), + type: CommonConfigurator.OwnerType.ORDER_ENTRY, + }; + + productConfiguration = { + configId: CONFIG_ID, + productCode: PRODUCT_CODE, + owner: OWNER_PRODUCT, + groups: [group1, group2], + }; + + configuratorUtils.setOwnerKey(OWNER_PRODUCT); + configuratorUtils.setOwnerKey(OWNER_CART_ENTRY); + + configurationState.configurations.entities[OWNER_PRODUCT.key] = { + ...productConfiguration, + loading: false, + }; + store = TestBed.inject(Store as Type>); + configuratorCartService = TestBed.inject( + ConfiguratorCartService as Type + ); + spyOn( + configuratorUtilsService, + 'createConfigurationExtract' + ).and.callThrough(); + }); + + it('should create service', () => { + expect(serviceUnderTest).toBeDefined(); + }); + + it('should call matching action on removeConfiguration', () => { + spyOn(store, 'dispatch').and.callThrough(); + serviceUnderTest.removeConfiguration(productConfiguration.owner); + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorActions.RemoveConfiguration({ + ownerKey: productConfiguration.owner.key, + }) + ); + }); + + it('should get pending changes from store', () => { + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => of(true)); + + let hasPendingChanges = null; + serviceUnderTest + .hasPendingChanges(OWNER_PRODUCT) + .subscribe((pendingChanges) => { + hasPendingChanges = pendingChanges; + }); + expect(hasPendingChanges).toBe(true); + }); + + it('should get configuration loading state from store', () => { + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(configurationState.configurations.entities[OWNER_PRODUCT.key]) + ); + + let isLoading = null; + serviceUnderTest + .isConfigurationLoading(OWNER_PRODUCT) + .subscribe((loading) => { + isLoading = loading; + }); + expect(isLoading).toBe(false); + }); + + it('should update a configuration, accessing the store', () => { + cart.code = 'X'; + cartObs = of(cart); + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(productConfiguration) + ); + const changedAttribute: Configurator.Attribute = { + name: ATTRIBUTE_NAME_1, + groupId: GROUP_ID_1, + }; + serviceUnderTest.updateConfiguration(OWNER_PRODUCT.key, changedAttribute); + + expect( + configuratorUtilsService.createConfigurationExtract + ).toHaveBeenCalled(); + }); + + it('should do nothing on update in case cart updates are pending', () => { + isStableObservable = of(false); + cart.code = 'X'; + cartObs = of(cart); + const changedAttribute: Configurator.Attribute = { + name: ATTRIBUTE_NAME_1, + groupId: GROUP_ID_1, + }; + serviceUnderTest.updateConfiguration(OWNER_PRODUCT.key, changedAttribute); + expect( + configuratorUtilsService.createConfigurationExtract + ).toHaveBeenCalledTimes(0); + }); + + it('should update a configuration in case no session cart is present yet, even when cart is busy', () => { + cart.code = undefined; + cartObs = of(cart); + isStableObservable = of(false); + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(productConfiguration) + ); + + const changedAttribute: Configurator.Attribute = { + name: ATTRIBUTE_NAME_1, + groupId: GROUP_ID_1, + }; + + serviceUnderTest.updateConfiguration(OWNER_PRODUCT.key, changedAttribute); + + expect( + configuratorUtilsService.createConfigurationExtract + ).toHaveBeenCalled(); + }); + + describe('getConfigurationWithOverview', () => { + it('should get an overview from occ, accessing the store', () => { + expect(productConfiguration.overview).toBeUndefined(); + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(productConfiguration) + ); + spyOn(store, 'dispatch').and.callThrough(); + serviceUnderTest + .getConfigurationWithOverview(productConfiguration) + .subscribe(() => { + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorActions.GetConfigurationOverview( + productConfiguration + ) + ); + //TODO: Add "done" callback + }) + .unsubscribe(); + }); + + it('should not dispatch an action if overview is already present', (done) => { + const configurationWithOverview: Configurator.Configuration = { + configId: CONFIG_ID, + overview: {}, + }; + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(configurationWithOverview) + ); + spyOn(store, 'dispatch').and.callThrough(); + serviceUnderTest + .getConfigurationWithOverview(productConfiguration) + .subscribe(() => { + expect(store.dispatch).toHaveBeenCalledTimes(0); + done(); + }) + .unsubscribe(); + }); + }); + + describe('getConfiguration', () => { + it('should return an unchanged observable of product configurations in case configurations carry valid config IDs', () => { + const obs = cold('x-y', { + x: productConfiguration, + y: productConfigurationChanged, + }); + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => obs); + const configurationObs = serviceUnderTest.getConfiguration( + productConfiguration.owner + ); + expect(configurationObs).toBeObservable(obs); + }); + + it('should filter incomplete configurations from store', () => { + const productConfigIncomplete = { + ...productConfigurationChanged, + configId: '', + }; + + const obs = cold('xy|', { + x: productConfiguration, + y: productConfigIncomplete, + }); + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => obs); + + const configurationObs = serviceUnderTest.getConfiguration( + productConfiguration.owner + ); + + expect(configurationObs).toBeObservable( + cold('x-|', { + x: productConfiguration, + }) + ); + }); + }); + + describe('getOrCreateConfiguration', () => { + it('should return an unchanged observable of product configurations in case configurations exist and carry valid config IDs', () => { + const configurationObs = callGetOrCreate(serviceUnderTest, OWNER_PRODUCT); + expect(configurationObs).toBeObservable( + cold('x-y', { + x: productConfiguration, + y: productConfigurationChanged, + }) + ); + }); + + it('should delegate to config cart service for cart bound configurations', () => { + spyOn( + configuratorCartService, + 'readConfigurationForCartEntry' + ).and.callThrough(); + + serviceUnderTest.getOrCreateConfiguration(OWNER_CART_ENTRY); + + expect( + configuratorCartService.readConfigurationForCartEntry + ).toHaveBeenCalledWith(OWNER_CART_ENTRY); + }); + + it('should delegate to config cart service for order bound configurations', () => { + spyOn( + configuratorCartService, + 'readConfigurationForOrderEntry' + ).and.callThrough(); + + serviceUnderTest.getOrCreateConfiguration(OWNER_ORDER_ENTRY); + + expect( + configuratorCartService.readConfigurationForOrderEntry + ).toHaveBeenCalledWith(OWNER_ORDER_ENTRY); + }); + + it('should create a new configuration if not existing yet', () => { + const productConfigurationLoaderState: StateUtils.LoaderState = { + loading: false, + }; + + const obs = cold('x', { + x: productConfigurationLoaderState, + }); + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => obs); + spyOn(store, 'dispatch').and.callThrough(); + + const configurationObs = serviceUnderTest.getOrCreateConfiguration( + OWNER_PRODUCT + ); + + expect(configurationObs).toBeObservable(cold('', {})); + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorActions.CreateConfiguration(OWNER_PRODUCT) + ); + }); + + it('should not create a new configuration if not existing yet but status is loading', () => { + const productConfigurationLoaderState: StateUtils.LoaderState = { + loading: true, + }; + + const obs = cold('x', { + x: productConfigurationLoaderState, + }); + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => obs); + spyOn(store, 'dispatch').and.callThrough(); + + const configurationObs = serviceUnderTest.getOrCreateConfiguration( + OWNER_PRODUCT + ); + + expect(configurationObs).toBeObservable(cold('', {})); + expect(store.dispatch).toHaveBeenCalledTimes(0); + }); + + it('should not create a new configuration if existing yet but erroneous', () => { + const productConfigurationLoaderState: StateUtils.LoaderState = { + loading: false, + error: true, + }; + + const obs = cold('x', { + x: productConfigurationLoaderState, + }); + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => obs); + spyOn(store, 'dispatch').and.callThrough(); + + const configurationObs = serviceUnderTest.getOrCreateConfiguration( + OWNER_PRODUCT + ); + + expect(configurationObs).toBeObservable(cold('', {})); + expect(store.dispatch).toHaveBeenCalledTimes(0); + }); + }); + + describe('hasConflicts', () => { + it('should return false in case of no conflicts', (done) => { + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(productConfiguration) + ); + serviceUnderTest + .hasConflicts(OWNER_PRODUCT) + .pipe() + .subscribe((hasConflicts) => { + expect(hasConflicts).toBe(false); + done(); + }) + .unsubscribe(); + }); + + it('should return true in case of conflicts', (done) => { + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(productConfigurationWithConflicts) + ); + serviceUnderTest + .hasConflicts(OWNER_PRODUCT) + .pipe() + .subscribe((hasConflicts) => { + expect(hasConflicts).toBe(true); + done(); + }) + .unsubscribe(); + }); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/core/facade/configurator-commons.service.ts b/feature-libs/product-configurator/rulebased/core/facade/configurator-commons.service.ts new file mode 100644 index 00000000000..4ad839cddcb --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/facade/configurator-commons.service.ts @@ -0,0 +1,246 @@ +import { Injectable, isDevMode } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { ActiveCartService } from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { filter, map, switchMap, switchMapTo, take, tap } from 'rxjs/operators'; +import { Configurator } from '../model/configurator.model'; +import { ConfiguratorActions } from '../state/actions/index'; +import { StateWithConfigurator } from '../state/configurator-state'; +import { ConfiguratorSelectors } from '../state/selectors/index'; +import { ConfiguratorCartService } from './configurator-cart.service'; +import { ConfiguratorUtilsService } from './utils/configurator-utils.service'; + +@Injectable({ providedIn: 'root' }) +export class ConfiguratorCommonsService { + constructor( + protected store: Store, + protected commonConfigUtilsService: CommonConfiguratorUtilsService, + protected configuratorCartService: ConfiguratorCartService, + protected activeCartService: ActiveCartService, + protected configuratorUtils: ConfiguratorUtilsService + ) {} + + /** + * Verifies whether there are any pending configuration changes. + * + * @param owner - Configuration owner + * + * @returns {Observable} Returns true if there are any pending changes, otherwise false + */ + hasPendingChanges(owner: CommonConfigurator.Owner): Observable { + return this.store.pipe( + select(ConfiguratorSelectors.hasPendingChanges(owner.key)) + ); + } + + /** + * Verifies whether the configuration is loading. + * + * @param owner - Configuration owner + * + * @returns {Observable} Returns true if the configuration is loading, otherwise false + */ + isConfigurationLoading(owner: CommonConfigurator.Owner): Observable { + return this.store.pipe( + select( + ConfiguratorSelectors.getConfigurationProcessLoaderStateFactory( + owner.key + ) + ), + map((configurationState) => configurationState.loading) + ); + } + + /** + * Returns a configuration for an owner. Emits only if there are valid configurations + * available for the requested owner, does not trigger the re-read or + * creation of the configuration in case it's not there + * + * @param owner - Configuration owner + * + * @returns {Observable} + */ + getConfiguration( + owner: CommonConfigurator.Owner + ): Observable { + return this.store.pipe( + select(ConfiguratorSelectors.getConfigurationFactory(owner.key)), + filter((configuration) => + this.configuratorUtils.isConfigurationCreated(configuration) + ) + ); + } + + /** + * Returns a configuration if it exists or creates a new one. + * Emits if there is a valid configuration available and triggers + * the configuration creation or read from backend in case it is not + * available + * + * @param owner - Configuration owner + * + * @returns {Observable} + */ + getOrCreateConfiguration( + owner: CommonConfigurator.Owner + ): Observable { + switch (owner.type) { + case CommonConfigurator.OwnerType.PRODUCT: { + return this.getOrCreateConfigurationForProduct(owner); + } + case CommonConfigurator.OwnerType.CART_ENTRY: { + return this.configuratorCartService.readConfigurationForCartEntry( + owner + ); + } + case CommonConfigurator.OwnerType.ORDER_ENTRY: { + return this.configuratorCartService.readConfigurationForOrderEntry( + owner + ); + } + } + } + + /** + * Updates a configuration, specified by the configuration owner key, group ID and a changed attribute. + * + * @param ownerKey - Configuration owner key + * @param changedAttribute - Changes attribute + */ + updateConfiguration( + ownerKey: string, + changedAttribute: Configurator.Attribute + ): void { + // in case cart updates pending: Do nothing, because an addToCart might + // be in progress. Can happen if on slow networks addToCart was hit and + // afterwards an attribute was changed before the OV navigation has + // taken place + this.activeCartService + .getActive() + .pipe( + take(1), + switchMap((cart) => + this.activeCartService.isStable().pipe( + take(1), + tap((stable) => { + if (isDevMode() && cart.code && !stable) { + console.warn('Cart is busy, no configuration updates possible'); + } + }), + filter((stable) => !cart.code || stable), + switchMapTo( + this.store.pipe( + select(ConfiguratorSelectors.getConfigurationFactory(ownerKey)), + take(1) + ) + ) + ) + ) + ) + .subscribe((configuration) => { + this.store.dispatch( + new ConfiguratorActions.UpdateConfiguration( + this.configuratorUtils.createConfigurationExtract( + changedAttribute, + configuration + ) + ) + ); + }); + } + + /** + * Returns a configuration with an overview. Emits valid configurations which + * include the overview aspect + * + * @param configuration - Configuration + * @returns Observable of configurations including the overview + */ + getConfigurationWithOverview( + configuration: Configurator.Configuration + ): Observable { + return this.store.pipe( + select( + ConfiguratorSelectors.getConfigurationFactory(configuration.owner.key) + ), + filter((config) => this.configuratorUtils.isConfigurationCreated(config)), + tap((configurationState) => { + if (!this.hasConfigurationOverview(configurationState)) { + this.store.dispatch( + new ConfiguratorActions.GetConfigurationOverview(configuration) + ); + } + }), + filter((config) => this.hasConfigurationOverview(config)) + ); + } + + /** + * Removes a configuration. + * + * @param owner - Configuration owner + */ + removeConfiguration(owner: CommonConfigurator.Owner): void { + this.store.dispatch( + new ConfiguratorActions.RemoveConfiguration({ ownerKey: owner.key }) + ); + } + + /** + * Checks if the configuration contains conflicts + * + * @param owner - Configuration owner + * + * @returns {Observable} - Returns true if the configuration has conflicts, otherwise false + */ + hasConflicts(owner: CommonConfigurator.Owner): Observable { + return this.getConfiguration(owner).pipe( + map( + (configuration) => + //We expect that the first group must always be the conflict group + configuration.groups[0]?.groupType === + Configurator.GroupType.CONFLICT_HEADER_GROUP + ) + ); + } + + protected getOrCreateConfigurationForProduct( + owner: CommonConfigurator.Owner + ): Observable { + return this.store.pipe( + select( + ConfiguratorSelectors.getConfigurationProcessLoaderStateFactory( + owner.key + ) + ), + + tap((configurationState) => { + if ( + !this.configuratorUtils.isConfigurationCreated( + configurationState.value + ) && + configurationState.loading !== true && + configurationState.error !== true + ) { + this.store.dispatch( + new ConfiguratorActions.CreateConfiguration(owner) + ); + } + }), + filter((configurationState) => + this.configuratorUtils.isConfigurationCreated(configurationState.value) + ), + map((configurationState) => configurationState.value) + ); + } + + protected hasConfigurationOverview( + configuration: Configurator.Configuration + ): boolean { + return configuration.overview !== undefined; + } +} 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 new file mode 100644 index 00000000000..61ddd984114 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/facade/configurator-group-status.service.spec.ts @@ -0,0 +1,116 @@ +import { Type } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { of } from 'rxjs'; +import { + GROUP_ID_1, + GROUP_ID_3, + GROUP_ID_4, + GROUP_ID_5, + GROUP_ID_6, + GROUP_ID_7, + GROUP_ID_8, + productConfiguration, + productConfigurationWithConflicts, +} from '../../shared/testing/configurator-test-data'; +import { ConfiguratorActions } from '../state/actions/index'; +import { StateWithConfigurator } from '../state/configurator-state'; +import { ConfiguratorGroupStatusService } from './configurator-group-status.service'; +import { ConfiguratorUtilsService } from './utils/configurator-utils.service'; + +describe('ConfiguratorGroupStatusService', () => { + let classUnderTest: ConfiguratorGroupStatusService; + let store: Store; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({})], + providers: [ConfiguratorUtilsService, ConfiguratorGroupStatusService], + }).compileComponents(); + }) + ); + + beforeEach(() => { + classUnderTest = TestBed.inject( + ConfiguratorGroupStatusService as Type + ); + store = TestBed.inject(Store as Type>); + + spyOn(store, 'dispatch').and.stub(); + spyOn(store, 'pipe').and.returnValue(of(productConfiguration)); + }); + + it('should be created', () => { + expect(classUnderTest).toBeTruthy(); + }); + + describe('Group Status Tests', () => { + it('should call setGroupVisisted action on setGroupStatus method call', () => { + classUnderTest.setGroupStatusVisited( + productConfiguration, + productConfiguration.groups[0].id + ); + + const expectedAction = new ConfiguratorActions.SetGroupsVisited({ + entityKey: productConfiguration.owner.key, + visitedGroups: [GROUP_ID_1], + }); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + + it('should get parent group, when all subgroups are visited', () => { + spyOn(store, 'select').and.returnValue(of(true)); + classUnderTest.setGroupStatusVisited(productConfiguration, GROUP_ID_4); + + const expectedAction = new ConfiguratorActions.SetGroupsVisited({ + entityKey: productConfiguration.owner.key, + visitedGroups: [GROUP_ID_4, GROUP_ID_3], + }); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + + it('should not get parent group, when not all subgroups are visited', () => { + //Not all subgroups are visited + spyOn(store, 'select').and.returnValue(of(false)); + + classUnderTest.setGroupStatusVisited(productConfiguration, GROUP_ID_6); + + const expectedAction = new ConfiguratorActions.SetGroupsVisited({ + entityKey: productConfiguration.owner.key, + visitedGroups: [GROUP_ID_6], + }); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + + it('should get all parent groups, when lowest subgroup are visited', () => { + spyOn(store, 'select').and.returnValue(of(true)); + + classUnderTest.setGroupStatusVisited(productConfiguration, GROUP_ID_8); + + const expectedAction = new ConfiguratorActions.SetGroupsVisited({ + entityKey: productConfiguration.owner.key, + visitedGroups: [GROUP_ID_8, GROUP_ID_7, GROUP_ID_5], + }); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + + it('should get first incomplete group', () => { + expect(classUnderTest.getFirstIncompleteGroup(productConfiguration)).toBe( + productConfiguration.flatGroups[0] + ); + }); + + it('should get first incomplete group - only consider non conflict groups', () => { + expect( + classUnderTest.getFirstIncompleteGroup( + productConfigurationWithConflicts + ) + ).toBe(productConfigurationWithConflicts.flatGroups[3]); + }); + }); +}); 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 new file mode 100644 index 00000000000..2b8340fd547 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/facade/configurator-group-status.service.ts @@ -0,0 +1,141 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { Configurator } from '../model/configurator.model'; +import { ConfiguratorActions } from '../state/actions/index'; +import { StateWithConfigurator } from '../state/configurator-state'; +import { ConfiguratorSelectors } from '../state/selectors/index'; +import { ConfiguratorUtilsService } from './utils/configurator-utils.service'; + +/** + * Service for handling group statuses + */ +@Injectable({ providedIn: 'root' }) +export class ConfiguratorGroupStatusService { + constructor( + protected store: Store, + protected configuratorUtilsService: ConfiguratorUtilsService + ) {} + + /** + * Verifies whether the group has been visited. + * + * @param {CommonConfigurator.Owner} owner - Configuration owner + * @param {string} groupId - Group ID + * @returns {Observable} Has group been visited? + */ + isGroupVisited( + owner: CommonConfigurator.Owner, + groupId: string + ): Observable { + return this.store.select( + ConfiguratorSelectors.isGroupVisited(owner.key, groupId) + ); + } + + /** + * Returns the first non-conflict group of the configuration which is not completed + * and undefined if all are completed. + * + * @param {Configurator.Configuration} configuration - Configuration + * + * @return {Configurator.Group} - First incomplete group or undefined + */ + getFirstIncompleteGroup( + configuration: Configurator.Configuration + ): Configurator.Group { + return configuration.flatGroups + .filter( + (group) => group.groupType !== Configurator.GroupType.CONFLICT_GROUP + ) + .find((group) => !group.complete); + } + + /** + * Determines whether the group has been visited or not. + * + * @param {Configurator.Configuration} configuration - Configuration + * @param {string} groupId - Group ID + */ + setGroupStatusVisited( + configuration: Configurator.Configuration, + groupId: string + ): void { + const group = this.configuratorUtilsService.getGroupById( + configuration.groups, + groupId + ); + const parentGroup = this.configuratorUtilsService.getParentGroup( + configuration.groups, + this.configuratorUtilsService.getGroupById(configuration.groups, groupId) + ); + + const visitedGroupIds = []; + visitedGroupIds.push(group.id); + this.getParentGroupStatusVisited( + configuration, + group.id, + parentGroup, + visitedGroupIds + ); + + this.store.dispatch( + new ConfiguratorActions.SetGroupsVisited({ + entityKey: configuration.owner.key, + visitedGroups: visitedGroupIds, + }) + ); + } + + protected areGroupsVisited( + owner: CommonConfigurator.Owner, + groupIds: string[] + ): Observable { + return this.store.select( + ConfiguratorSelectors.areGroupsVisited(owner.key, groupIds) + ); + } + + protected getParentGroupStatusVisited( + configuration: Configurator.Configuration, + groupId: string, + parentGroup: Configurator.Group, + visitedGroupIds: string[] + ) { + if (parentGroup === null) { + return; + } + + const subGroups = []; + parentGroup.subGroups.forEach((subGroup) => { + //The current group is not set to visited yet, therefore we have to exclude it in the check + if (subGroup.id === groupId) { + return; + } + subGroups.push(subGroup.id); + }); + + this.areGroupsVisited(configuration.owner, subGroups) + .pipe(take(1)) + .subscribe((isVisited) => { + if (isVisited) { + visitedGroupIds.push(parentGroup.id); + + this.getParentGroupStatusVisited( + configuration, + parentGroup.id, + this.configuratorUtilsService.getParentGroup( + configuration.groups, + this.configuratorUtilsService.getGroupById( + configuration.groups, + parentGroup.id + ) + ), + visitedGroupIds + ); + } + }); + } +} 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 new file mode 100644 index 00000000000..47a69661914 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/facade/configurator-groups.service.spec.ts @@ -0,0 +1,365 @@ +import { Type } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { ActiveCartService } from '@spartacus/core'; +import { Observable, of } from 'rxjs'; +import { + GROUP_ID_1, + GROUP_ID_2, + GROUP_ID_4, + productConfiguration, + productConfigurationWithConflicts, +} from '../../shared/testing/configurator-test-data'; +import { ConfiguratorActions } from '../state/actions/index'; +import { StateWithConfigurator } from '../state/configurator-state'; +import { Configurator } from './../model/configurator.model'; +import { ConfiguratorCartService } from './configurator-cart.service'; +import { ConfiguratorCommonsService } from './configurator-commons.service'; +import { ConfiguratorGroupStatusService } from './configurator-group-status.service'; +import { ConfiguratorGroupsService } from './configurator-groups.service'; +import { ConfiguratorUtilsService } from './utils/configurator-utils.service'; + +class MockActiveCartService {} +class MockConfiguratorCartService { + checkForActiveCartUpdateDone(): Observable { + return of(true); + } +} + +describe('ConfiguratorGroupsService', () => { + let classUnderTest: ConfiguratorGroupsService; + let store: Store; + let configuratorCommonsService: ConfiguratorCommonsService; + let configGroupStatusService: ConfiguratorGroupStatusService; + let configFacadeUtilsService: ConfiguratorUtilsService; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({})], + providers: [ + ConfiguratorGroupsService, + ConfiguratorCommonsService, + ConfiguratorGroupStatusService, + ConfiguratorUtilsService, + { + provide: ActiveCartService, + useClass: MockActiveCartService, + }, + { + provide: ConfiguratorCartService, + useClass: MockConfiguratorCartService, + }, + ], + }).compileComponents(); + }) + ); + beforeEach(() => { + classUnderTest = TestBed.inject( + ConfiguratorGroupsService as Type + ); + store = TestBed.inject(Store as Type>); + configuratorCommonsService = TestBed.inject( + ConfiguratorCommonsService as Type + ); + configGroupStatusService = TestBed.inject( + ConfiguratorGroupStatusService as Type + ); + configFacadeUtilsService = TestBed.inject( + ConfiguratorUtilsService as Type + ); + + spyOn(store, 'dispatch').and.stub(); + spyOn(store, 'pipe').and.returnValue(of(productConfiguration)); + + spyOn(configGroupStatusService, 'setGroupStatusVisited').and.callThrough(); + spyOn(configGroupStatusService, 'isGroupVisited').and.callThrough(); + spyOn(configFacadeUtilsService, 'getParentGroup').and.callThrough(); + spyOn(configFacadeUtilsService, 'hasSubGroups').and.callThrough(); + spyOn(configFacadeUtilsService, 'getGroupById').and.callThrough(); + }); + + it('should create service', () => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(productConfiguration) + ); + expect(classUnderTest).toBeDefined(); + }); + + describe('getCurrentGroupId', () => { + it('should return a current group ID from state', (done) => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(productConfiguration) + ); + const currentGroup = classUnderTest.getCurrentGroupId( + productConfiguration.owner + ); + + expect(currentGroup).toBeDefined(); + currentGroup.subscribe((groupId) => { + expect(groupId).toBe(GROUP_ID_2); + done(); + }); + }); + + it('should return a current group ID from configuration', (done) => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of({ + ...productConfiguration, + interactionState: { currentGroup: null }, + }) + ); + const currentGroup = classUnderTest.getCurrentGroupId( + productConfiguration.owner + ); + + expect(currentGroup).toBeDefined(); + currentGroup.subscribe((groupId) => { + expect(groupId).toBe(GROUP_ID_1); + done(); + }); + }); + + it('should return null if no group exist', (done) => { + const configNoGroups: Configurator.Configuration = { + configId: 'abc', + flatGroups: undefined, + }; + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(configNoGroups) + ); + + const currentGroupId = classUnderTest.getCurrentGroupId( + productConfiguration.owner + ); + currentGroupId.subscribe((groupId) => { + expect(groupId).toBeNull(); + done(); + }); + }); + }); + + it('should get the parentGroup from uiState', (done) => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(productConfiguration) + ); + const parentGroup = classUnderTest.getMenuParentGroup( + productConfiguration.owner + ); + + expect(parentGroup).toBeDefined(); + parentGroup.subscribe((group) => { + expect(group).toBe(productConfiguration.groups[2]); + done(); + }); + }); + + describe('getNextGroupId', () => { + it('should return a next group', (done) => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(productConfiguration) + ); + const currentGroup = classUnderTest.getNextGroupId( + productConfiguration.owner + ); + + expect(currentGroup).toBeDefined(); + currentGroup.subscribe((groupId) => { + expect(groupId).toBe(GROUP_ID_4); + done(); + }); + }); + }); + + describe('getPreviousGroupId', () => { + it('should return null', (done) => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(undefined) + ); + const currentGroup = classUnderTest.getPreviousGroupId( + productConfiguration.owner + ); + + expect(currentGroup).toBeDefined(); + currentGroup.subscribe((groupId) => { + expect(groupId).toEqual(null); + done(); + }); + }); + + it('should return a previous group ID', (done) => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(productConfiguration) + ); + const currentGroup = classUnderTest.getPreviousGroupId( + productConfiguration.owner + ); + + expect(currentGroup).toBeDefined(); + currentGroup.subscribe((groupId) => { + expect(groupId).toBe(GROUP_ID_1); + done(); + }); + }); + }); + + describe('setGroupStatusVisited', () => { + it('should call setGroupStatusVisited of groupStatusService', () => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(productConfiguration) + ); + classUnderTest.setGroupStatusVisited( + productConfiguration.owner, + productConfiguration.groups[0].id + ); + + expect(configGroupStatusService.setGroupStatusVisited).toHaveBeenCalled(); + }); + }); + + it('should delegate setting the parent group to the store', () => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(productConfiguration) + ); + classUnderTest.setMenuParentGroup(productConfiguration.owner, GROUP_ID_1); + const expectedAction = new ConfiguratorActions.SetMenuParentGroup({ + entityKey: productConfiguration.owner.key, + menuParentGroup: GROUP_ID_1, + }); + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + + it('should call group status in navigate to different group', () => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(productConfiguration) + ); + classUnderTest.navigateToGroup( + productConfiguration, + productConfiguration.groups[2].id + ); + + expect(configGroupStatusService.setGroupStatusVisited).toHaveBeenCalled(); + }); + + it('should check whether isGroupVisited has been called by the configuration group utils service', () => { + classUnderTest.isGroupVisited(productConfiguration.owner, GROUP_ID_4); + expect(configGroupStatusService.isGroupVisited).toHaveBeenCalledWith( + productConfiguration.owner, + GROUP_ID_4 + ); + expect(configGroupStatusService.isGroupVisited).toHaveBeenCalled(); + }); + + it('should get first conflict group from configuration, no conflicts', () => { + expect(classUnderTest.getFirstConflictGroup(productConfiguration)).toBe( + undefined + ); + }); + + it('should get first conflict group from configuration', () => { + expect( + classUnderTest.getFirstConflictGroup(productConfigurationWithConflicts) + ).toBe(productConfigurationWithConflicts.flatGroups[0]); + }); + + it('should go to conflict solver', () => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(productConfigurationWithConflicts) + ); + classUnderTest.navigateToConflictSolver( + productConfigurationWithConflicts.owner + ); + + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorActions.ChangeGroup({ + configuration: productConfigurationWithConflicts, + groupId: productConfigurationWithConflicts.flatGroups[0].id, + parentGroupId: productConfigurationWithConflicts.groups[0].id, + }) + ); + }); + + it('should set change group to undefined it no conflict group exists (caller has to verify this)', () => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(productConfiguration) + ); + + classUnderTest.navigateToConflictSolver( + productConfigurationWithConflicts.owner + ); + + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorActions.ChangeGroup({ + configuration: productConfiguration, + groupId: undefined, + parentGroupId: null, + }) + ); + }); + + it('should go to first incomplete group', () => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(productConfiguration) + ); + classUnderTest.navigateToFirstIncompleteGroup(productConfiguration.owner); + + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorActions.ChangeGroup({ + configuration: productConfiguration, + groupId: productConfiguration.flatGroups[0].id, + parentGroupId: null, + }) + ); + }); + + it('should set change group to undefined if no incomplete group exists (caller has to verify this)', () => { + spyOn(configuratorCommonsService, 'getConfiguration').and.returnValue( + of(productConfiguration) + ); + spyOn(configGroupStatusService, 'getFirstIncompleteGroup').and.returnValue( + undefined + ); + classUnderTest.navigateToFirstIncompleteGroup(productConfiguration.owner); + + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorActions.ChangeGroup({ + configuration: productConfiguration, + groupId: undefined, + parentGroupId: null, + }) + ); + }); + + it('should delegate calls for parent group to the facade utils service', () => { + classUnderTest.getParentGroup( + productConfiguration.groups, + productConfiguration.groups[2].subGroups[0] + ); + expect(configFacadeUtilsService.getParentGroup).toHaveBeenCalledWith( + productConfiguration.groups, + productConfiguration.groups[2].subGroups[0] + ); + }); + + it('should delegate calls for sub groups to the facade utils service', () => { + classUnderTest.hasSubGroups(productConfiguration.groups[2]); + expect(configFacadeUtilsService.hasSubGroups).toHaveBeenCalledWith( + productConfiguration.groups[2] + ); + expect(configFacadeUtilsService.hasSubGroups).toHaveBeenCalled(); + }); + + it('should return true if groupType is a conflict group type otherwise false', () => { + expect( + classUnderTest.isConflictGroupType( + Configurator.GroupType.CONFLICT_HEADER_GROUP + ) + ).toBe(true); + expect( + classUnderTest.isConflictGroupType(Configurator.GroupType.CONFLICT_GROUP) + ).toBe(true); + expect( + classUnderTest.isConflictGroupType(Configurator.GroupType.ATTRIBUTE_GROUP) + ).toBe(false); + }); +}); 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 new file mode 100644 index 00000000000..0fb49fb8908 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/facade/configurator-groups.service.ts @@ -0,0 +1,337 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable, of } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators'; +import { Configurator } from '../model/configurator.model'; +import { ConfiguratorActions } from '../state/actions/index'; +import { StateWithConfigurator } from '../state/configurator-state'; +import { ConfiguratorCommonsService } from './configurator-commons.service'; +import { ConfiguratorGroupStatusService } from './configurator-group-status.service'; +import { ConfiguratorUtilsService } from './utils/configurator-utils.service'; + +/** + * Service for handling configuration groups + */ +@Injectable({ providedIn: 'root' }) +export class ConfiguratorGroupsService { + constructor( + protected store: Store, + protected configuratorCommonsService: ConfiguratorCommonsService, + protected configuratorUtilsService: ConfiguratorUtilsService, + protected configuratorGroupStatusService: ConfiguratorGroupStatusService + ) {} + + /** + * Returns the current group Id. + * In case no group Id is being set before returns the first group of the configuration. + * Return null when configuration contains no groups. + * + * @param {CommonConfigurator.Owner} owner configuration owner + * @returns {Observable} Group ID + */ + getCurrentGroupId(owner: CommonConfigurator.Owner): Observable { + return this.configuratorCommonsService.getConfiguration(owner).pipe( + map((configuration) => { + if (configuration?.interactionState?.currentGroup) { + return configuration.interactionState.currentGroup; + } else { + return configuration?.flatGroups?.length > 0 + ? configuration.flatGroups[0].id + : null; + } + }) + ); + } + + /** + * Return the first conflict group of a configuration or undefined + * if not present + * + * @param {Configurator.Configuration} configuration - Configuration + * @returns {Configurator.Group} Conflict group + */ + getFirstConflictGroup( + configuration: Configurator.Configuration + ): Configurator.Group { + return configuration.flatGroups.find( + (group) => group.groupType === Configurator.GroupType.CONFLICT_GROUP + ); + } + + /** + * 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. + * + * @param {CommonConfigurator.Owner} owner - Configuration owner + */ + navigateToFirstIncompleteGroup(owner: CommonConfigurator.Owner): void { + this.configuratorCommonsService + .getConfiguration(owner) + .pipe(take(1)) + .subscribe((configuration) => + this.navigateToGroup( + configuration, + this.configuratorGroupStatusService.getFirstIncompleteGroup( + configuration + )?.id, + true + ) + ); + } + + /** + * Navigates to the first conflict group and sets the conflict header as parent group. + * This method assumes that the configuration has conflicts, + * the caller has to verify this prior to calling this method. + * + * @param {CommonConfigurator.Owner} owner Configuration Owner + */ + navigateToConflictSolver(owner: CommonConfigurator.Owner): void { + this.configuratorCommonsService + .getConfiguration(owner) + .pipe(take(1)) + .subscribe((configuration) => + this.navigateToGroup( + configuration, + this.getFirstConflictGroup(configuration)?.id, + true + ) + ); + } + + /** + * Returns the parent group of the subgroup that is displayed in the group menu. + * + * @param {CommonConfigurator.Owner} owner - Configuration owner + * @returns {Observable} Group + */ + getMenuParentGroup( + owner: CommonConfigurator.Owner + ): Observable { + return this.configuratorCommonsService + .getConfiguration(owner) + .pipe( + map((configuration) => + this.configuratorUtilsService.getGroupById( + configuration.groups, + configuration.interactionState.menuParentGroup + ) + ) + ); + } + + /** + * Set the parent group, specified by the group ID, which is displayed in the group menu. + * + * @param {CommonConfigurator.Owner} owner - Configuration owner + * @param {string} groupId - Group ID + */ + setMenuParentGroup(owner: CommonConfigurator.Owner, groupId: string): void { + this.store.dispatch( + new ConfiguratorActions.SetMenuParentGroup({ + entityKey: owner.key, + menuParentGroup: groupId, + }) + ); + } + + /** + * Returns the group that is currently visited. + * + * @param {CommonConfigurator.Owner} owner - Configuration owner + * @return {Observable} Current group + */ + getCurrentGroup( + owner: CommonConfigurator.Owner + ): Observable { + return this.getCurrentGroupId(owner).pipe( + switchMap((currentGroupId) => { + if (!currentGroupId) { + return null; + } + return this.configuratorCommonsService + .getConfiguration(owner) + .pipe( + map((configuration) => + this.configuratorUtilsService.getGroupById( + configuration.groups, + currentGroupId + ) + ) + ); + }) + ); + } + + /** + * Determines whether the group has been visited or not. + * + * @param {CommonConfigurator.Owner} owner - Owner + * @param {string} groupId - Group ID + */ + setGroupStatusVisited( + owner: CommonConfigurator.Owner, + groupId: string + ): void { + this.configuratorCommonsService + .getConfiguration(owner) + .pipe( + map((configuration) => + this.configuratorGroupStatusService.setGroupStatusVisited( + configuration, + groupId + ) + ), + take(1) + ) + .subscribe(); + } + + /** + * Navigates to the group, specified by its group ID. + * + * @param {Configurator.Configuration}configuration - Configuration + * @param {string} groupId - Group ID + * @param {boolean} setStatus - Group status will be set for previous group, default true + */ + navigateToGroup( + configuration: Configurator.Configuration, + groupId: string, + setStatus = true + ): void { + if (setStatus) { + //Set Group status for current group + this.getCurrentGroup(configuration.owner) + .pipe(take(1)) + .subscribe((currentGroup) => { + this.configuratorGroupStatusService.setGroupStatusVisited( + configuration, + currentGroup.id + ); + }); + } + + const parentGroup = this.configuratorUtilsService.getParentGroup( + configuration.groups, + this.configuratorUtilsService.getGroupById(configuration.groups, groupId) + ); + + this.store.dispatch( + new ConfiguratorActions.ChangeGroup({ + configuration: configuration, + groupId: groupId, + parentGroupId: parentGroup ? parentGroup.id : null, + }) + ); + } + + /** + * Returns the group ID of the group that is coming after the current one in a sequential order. + * + * @param {CommonConfigurator.Owner} owner - Configuration owner + * @return {Observable} ID of next group + */ + getNextGroupId(owner: CommonConfigurator.Owner): Observable { + return this.getNeighboringGroupId(owner, 1); + } + + /** + * Returns the group ID of the group that is preceding the current one in a sequential order. + * + * @param {CommonConfigurator.Owner} owner - Configuration owner + * @return {Observable} ID of previous group + */ + getPreviousGroupId(owner: CommonConfigurator.Owner): Observable { + return this.getNeighboringGroupId(owner, -1); + } + + /** + * Verifies whether the group has been visited + * + * @param {CommonConfigurator.Owner} owner - Configuration owner + * @param {string} groupId - Group ID + * @return {Observable} Has been visited? + */ + isGroupVisited( + owner: CommonConfigurator.Owner, + groupId: string + ): Observable { + return this.configuratorGroupStatusService.isGroupVisited(owner, groupId); + } + + /** + * Returns a parent group for the given group. + * + * @param {Configurator.Group[]} groups - List of groups where we search for the parent group + * @param {Configurator.Group} group - Given group + * @return {Configurator.Group} Parent group or null if group is a top-level group + */ + getParentGroup( + groups: Configurator.Group[], + group: Configurator.Group + ): Configurator.Group { + return this.configuratorUtilsService.getParentGroup(groups, group); + } + + /** + * Verifies whether the given group has sub groups. + * + * @param {Configurator.Group} group - Given group + * @return {boolean} Sub groups available? + */ + hasSubGroups(group: Configurator.Group): boolean { + return this.configuratorUtilsService.hasSubGroups(group); + } + + /** + * Retrieves a group ID of the neighboring group. + * + * @param {CommonConfigurator.Owner} owner - Configuration owner + * @param {number} neighboringIndex - Index of neighboring group + * @return {Observable} group ID of the neighboring group + */ + protected getNeighboringGroupId( + owner: CommonConfigurator.Owner, + neighboringIndex: number + ): Observable { + return this.getCurrentGroupId(owner).pipe( + switchMap((currentGroupId) => { + if (!currentGroupId) { + return of(null); + } + + return this.configuratorCommonsService.getConfiguration(owner).pipe( + map((configuration) => { + let nextGroup = null; + configuration.flatGroups.forEach((group, index) => { + if ( + group.id === currentGroupId && + configuration.flatGroups[index + neighboringIndex] //Check if neighboring group exists + ) { + nextGroup = + configuration.flatGroups[index + neighboringIndex].id; + } + }); + return nextGroup; + }), + take(1) + ); + }) + ); + } + + /** + * 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'. + */ + isConflictGroupType(groupType: Configurator.GroupType): boolean { + return ( + groupType === Configurator.GroupType.CONFLICT_HEADER_GROUP || + groupType === Configurator.GroupType.CONFLICT_GROUP + ); + } +} diff --git a/feature-libs/product-configurator/rulebased/core/facade/index.ts b/feature-libs/product-configurator/rulebased/core/facade/index.ts new file mode 100644 index 00000000000..396e87250a1 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/facade/index.ts @@ -0,0 +1,5 @@ +export * from './configurator-cart.service'; +export * from './configurator-commons.service'; +export * from './configurator-group-status.service'; +export * from './configurator-groups.service'; +export * from './utils/index'; diff --git a/feature-libs/product-configurator/rulebased/core/facade/utils/configurator-utils.service.spec.ts b/feature-libs/product-configurator/rulebased/core/facade/utils/configurator-utils.service.spec.ts new file mode 100644 index 00000000000..751253936e7 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/facade/utils/configurator-utils.service.spec.ts @@ -0,0 +1,315 @@ +import { Type } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { + ATTRIBUTE_1_CHECKBOX, + GROUP_ID_1, + GROUP_ID_2, + productConfiguration, +} from '../../../shared/testing/configurator-test-data'; +import { Configurator } from '../../model/configurator.model'; +import { ConfiguratorUtilsService } from './configurator-utils.service'; + +const CONFIG_ID = '1234-56-7890'; + +const GROUP_ID_3 = '23456-45-2'; +const GROUP_ID_31 = '23456-75-2'; +const GROUP_ID_4_ROOT = '23456-45-3'; +const GROUP_NAME = 'Software'; +const GROUP_NAME_2 = 'Hardware'; +const GROUP_NAME_LEVEL1_CHILD = 'Child group 1'; +const GROUP_NAME_LEVEL1_CHILD_2 = 'Child group 2'; +const GROUP_ROOT = 'Root level group'; + +const ATTRIBUTE_NAME_2 = 'Attribute_DropDown'; +const ATTRIBUTE_NAME_3_1 = 'Attribute_1'; +const ATTRIBUTE_NAME_3_2 = 'Attribute_DropDown'; +const PRODUCT_CODE = 'CONF_LAPTOP'; +const OWNER_PRODUCT: CommonConfigurator.Owner = {}; +const group1: Configurator.Group = { + id: GROUP_ID_1, + name: GROUP_NAME, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + attributes: [ + { + name: ATTRIBUTE_1_CHECKBOX, + uiType: Configurator.UiType.STRING, + userInput: 'input', + }, + { + name: ATTRIBUTE_NAME_2, + uiType: Configurator.UiType.DROPDOWN, + userInput: null, + }, + ], +}; + +const group2: Configurator.Group = { + id: GROUP_ID_2, + name: GROUP_NAME_2, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, +}; + +const group31: Configurator.Group = { + id: GROUP_ID_31, + name: GROUP_NAME_LEVEL1_CHILD_2, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, +}; + +const group3: Configurator.Group = { + id: GROUP_ID_3, + name: GROUP_NAME_LEVEL1_CHILD, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + subGroups: [group1, group2], + attributes: [ + { + name: ATTRIBUTE_NAME_3_1, + uiType: Configurator.UiType.STRING, + userInput: 'input', + }, + { + name: ATTRIBUTE_NAME_3_2, + uiType: Configurator.UiType.DROPDOWN, + userInput: null, + }, + ], +}; + +const group4: Configurator.Group = { + id: GROUP_ID_4_ROOT, + name: GROUP_ROOT, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + subGroups: [group3, group31], +}; + +const productConfigurationMultiLevel: Configurator.Configuration = { + configId: CONFIG_ID, + productCode: PRODUCT_CODE, + owner: OWNER_PRODUCT, + groups: [group4], +}; + +function mergeChangesAndGetFirstGroup( + serviceUnderTest: ConfiguratorUtilsService, + changedAttribute: Configurator.Attribute, + configuration: Configurator.Configuration +) { + const configurationForSendingChanges = serviceUnderTest.createConfigurationExtract( + changedAttribute, + configuration + ); + expect(configurationForSendingChanges).toBeDefined(); + const groups = configurationForSendingChanges.groups; + expect(groups).toBeDefined(); + expect(groups.length).toBe(1); + const groupForUpdateRequest = groups[0]; + return groupForUpdateRequest; +} + +describe('ConfiguratorGroupUtilsService', () => { + let classUnderTest: ConfiguratorUtilsService; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [ConfiguratorUtilsService], + }).compileComponents(); + }) + ); + + beforeEach(() => { + classUnderTest = TestBed.inject( + ConfiguratorUtilsService as Type + ); + }); + + it('should be created', () => { + expect(classUnderTest).toBeTruthy(); + }); + + it('should find group from group Id', () => { + const group = classUnderTest.getGroupById( + productConfiguration.groups, + GROUP_ID_2 + ); + + expect(group).toBe(productConfiguration.groups[1]); + }); + + it('should find parent group from group', () => { + const parentGroup = classUnderTest.getParentGroup( + productConfiguration.groups, + productConfiguration.groups[2].subGroups[0], + null + ); + + expect(parentGroup).toBe(productConfiguration.groups[2]); + }); + + it('should check if subgroups exist', () => { + expect(classUnderTest.hasSubGroups(productConfiguration.groups[0])).toBe( + false + ); + expect(classUnderTest.hasSubGroups(productConfiguration.groups[2])).toBe( + true + ); + }); + + describe('isConfigurationCreated', () => { + it('should tell from undefined config', () => { + const configuration: Configurator.Configuration = undefined; + expect(classUnderTest.isConfigurationCreated(configuration)).toBe(false); + }); + it('should tell from config ID', () => { + const configuration: Configurator.Configuration = { + configId: 'a', + flatGroups: [], + }; + expect(classUnderTest.isConfigurationCreated(configuration)).toBe(true); + }); + it('should tell from blank config ID', () => { + const configuration: Configurator.Configuration = { + configId: '', + flatGroups: [], + }; + expect(classUnderTest.isConfigurationCreated(configuration)).toBe(false); + }); + it('should know that config is not created in case the groups are not defined', () => { + const configuration: Configurator.Configuration = { configId: 'a' }; + expect(classUnderTest.isConfigurationCreated(configuration)).toBe(false); + }); + it('should know that config is created in case the groups are not defined but the overview aspect exists due to an order history read', () => { + const configuration: Configurator.Configuration = { + configId: 'a', + overview: {}, + }; + expect(classUnderTest.isConfigurationCreated(configuration)).toBe(true); + }); + }); + + describe('buildGroupPath', () => { + it('should create a group path for a single level model', () => { + const groupPath: Configurator.Group[] = []; + classUnderTest.buildGroupPath( + GROUP_ID_1, + productConfiguration.groups, + groupPath + ); + expect(groupPath.length).toBe(1); + expect(groupPath[0].id).toBe(GROUP_ID_1); + }); + + it('should create an empty group path for a single level model in case ID does not match', () => { + const groupPath: Configurator.Group[] = []; + classUnderTest.buildGroupPath( + 'Not known', + productConfiguration.groups, + groupPath + ); + expect(groupPath.length).toBe(0); + }); + + it('should create a group path for a multi level model', () => { + const groupPath: Configurator.Group[] = []; + classUnderTest.buildGroupPath( + GROUP_ID_1, + productConfigurationMultiLevel.groups, + groupPath + ); + expect(groupPath.length).toBe( + 3, + 'Expected path or 3 groups but was: ' + JSON.stringify(groupPath) + ); + expect(groupPath[2].name).toBe(GROUP_ROOT); + expect(groupPath[0].name).toBe(GROUP_NAME); + }); + + it('should create an empty group path for a multi level model in case ID does not match', () => { + const groupPath: Configurator.Group[] = []; + classUnderTest.buildGroupPath( + 'Not known', + productConfigurationMultiLevel.groups, + groupPath + ); + expect(groupPath.length).toBe(0); + }); + }); + + describe('createConfigurationExtract', () => { + it('should create a new configuration object for changes received, containing one group', () => { + const changedAttribute: Configurator.Attribute = { + name: ATTRIBUTE_1_CHECKBOX, + groupId: GROUP_ID_1, + }; + + const groupForUpdateRequest = mergeChangesAndGetFirstGroup( + classUnderTest, + changedAttribute, + productConfiguration + ); + expect(groupForUpdateRequest.id).toBe(GROUP_ID_1); + //group name not needed for update + expect(groupForUpdateRequest.name).toBeUndefined(); + expect(groupForUpdateRequest.groupType).toBe( + Configurator.GroupType.ATTRIBUTE_GROUP + ); + }); + + it('should be able to handle multilevel configurations as well, returning a projection of the original configuration with only the path to the changes', () => { + const changedAttribute: Configurator.Attribute = { + name: ATTRIBUTE_1_CHECKBOX, + groupId: GROUP_ID_1, + }; + + const groupForUpdateRequest = mergeChangesAndGetFirstGroup( + classUnderTest, + changedAttribute, + productConfigurationMultiLevel + ); + expect(groupForUpdateRequest.id).toBe(GROUP_ID_4_ROOT); + expect(groupForUpdateRequest.name).toBeUndefined(); + expect(groupForUpdateRequest.groupType).toBe( + Configurator.GroupType.ATTRIBUTE_GROUP + ); + + expect(groupForUpdateRequest.subGroups.length).toBe(1); + expect(groupForUpdateRequest.subGroups[0].subGroups.length).toBe(1); + expect( + groupForUpdateRequest.subGroups[0].subGroups[0].attributes + ).toEqual([changedAttribute]); + }); + + it('should create a new configuration object for changes received, containing exactly one attribute as part of the current group', () => { + const changedAttribute: Configurator.Attribute = { + name: ATTRIBUTE_1_CHECKBOX, + groupId: GROUP_ID_1, + }; + + const groupForUpdateRequest = mergeChangesAndGetFirstGroup( + classUnderTest, + changedAttribute, + productConfiguration + ); + const attributes = groupForUpdateRequest.attributes; + expect(attributes).toBeDefined( + 'We expect changed attributes in configuration for the update request' + ); + expect(attributes.length).toBe(1); + expect(attributes[0]).toBe(changedAttribute); + }); + + it('should throw an error if group for change is not part of the configuration', () => { + const changedAttribute: Configurator.Attribute = { + name: ATTRIBUTE_1_CHECKBOX, + groupId: 'unknown', + }; + + expect(function () { + classUnderTest.createConfigurationExtract( + changedAttribute, + productConfiguration + ); + }).toThrow(); + }); + }); +}); 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 new file mode 100644 index 00000000000..eecbe8518f2 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/facade/utils/configurator-utils.service.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@angular/core'; +import { Configurator } from '../../model/configurator.model'; + +/** + * Utility service for the facade layer. Supposed to be accessed by facade services + */ +@Injectable({ providedIn: 'root' }) +export class ConfiguratorUtilsService { + /** + * Determines the direct parent group for an attribute group + * @param groups List of groups where we search for parent + * @param group If already part of groups, no further search is needed, and we return the provided parent group + * @param parentGroup Optional. + * @returns Parent group. Might be null + */ + getParentGroup( + groups: Configurator.Group[], + group: Configurator.Group, + parentGroup: Configurator.Group = null + ): Configurator.Group { + if (groups.includes(group)) { + return parentGroup; + } + + return groups + .map((currentGroup) => + this.getParentGroup(currentGroup.subGroups, group, currentGroup) + ) + .filter((foundGroup) => foundGroup) + .pop(); + } + + getGroupById( + groups: Configurator.Group[], + groupId: string + ): Configurator.Group { + const currentGroup = groups.find((group) => group.id === groupId); + if (currentGroup) { + return currentGroup; + } + + return groups + .map((group) => this.getGroupById(group.subGroups, groupId)) + .filter((foundGroup) => foundGroup) + .pop(); + } + + hasSubGroups(group: Configurator.Group): boolean { + return group.subGroups ? group.subGroups.length > 0 : false; + } + + isConfigurationCreated(configuration: Configurator.Configuration): boolean { + const configId: String = configuration?.configId; + return ( + configId !== undefined && + configId.length !== 0 && + (configuration?.flatGroups !== undefined || + configuration?.overview !== undefined) + ); + } + + createConfigurationExtract( + changedAttribute: Configurator.Attribute, + configuration: Configurator.Configuration + ): Configurator.Configuration { + const newConfiguration: Configurator.Configuration = { + configId: configuration.configId, + groups: [], + owner: configuration.owner, + productCode: configuration.productCode, + }; + + const groupPath: Configurator.Group[] = []; + this.buildGroupPath( + changedAttribute.groupId, + configuration.groups, + groupPath + ); + const groupPathLength = groupPath.length; + if (groupPathLength === 0) { + throw new Error( + 'At this point we expect that group is available in the configuration: ' + + changedAttribute.groupId + + ', ' + + JSON.stringify(configuration.groups.map((cGroup) => cGroup.id)) + ); + } + let currentGroupInExtract: Configurator.Group = this.buildGroupForExtract( + groupPath[groupPathLength - 1] + ); + let currentLeafGroupInExtract: Configurator.Group = currentGroupInExtract; + newConfiguration.groups.push(currentGroupInExtract); + + for (let index = groupPath.length - 1; index > 0; index--) { + currentLeafGroupInExtract = this.buildGroupForExtract( + groupPath[index - 1] + ); + currentGroupInExtract.subGroups = [currentLeafGroupInExtract]; + currentGroupInExtract = currentLeafGroupInExtract; + } + + currentLeafGroupInExtract.attributes = [changedAttribute]; + return newConfiguration; + } + + buildGroupPath( + groupId: string, + groupList: Configurator.Group[], + groupPath: Configurator.Group[] + ): boolean { + let haveFoundGroup = false; + const group: Configurator.Group = groupList.find( + (currentGroup) => currentGroup.id === groupId + ); + + if (group) { + groupPath.push(group); + haveFoundGroup = true; + } else { + groupList + .filter((currentGroup) => currentGroup.subGroups) + .forEach((currentGroup) => { + if (this.buildGroupPath(groupId, currentGroup.subGroups, groupPath)) { + groupPath.push(currentGroup); + haveFoundGroup = true; + } + }); + } + return haveFoundGroup; + } + protected buildGroupForExtract( + group: Configurator.Group + ): Configurator.Group { + const changedGroup: Configurator.Group = { + groupType: group.groupType, + id: group.id, + }; + return changedGroup; + } +} diff --git a/feature-libs/product-configurator/rulebased/core/facade/utils/index.ts b/feature-libs/product-configurator/rulebased/core/facade/utils/index.ts new file mode 100644 index 00000000000..7b6886fee5b --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/facade/utils/index.ts @@ -0,0 +1 @@ +export * from './configurator-utils.service'; diff --git a/feature-libs/product-configurator/rulebased/core/index.ts b/feature-libs/product-configurator/rulebased/core/index.ts new file mode 100644 index 00000000000..752556e3f3e --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/index.ts @@ -0,0 +1,5 @@ +export * from './connectors/index'; +export * from './facade/index'; +export * from './model/index'; +export * from './rulebased-configurator-core.module'; +export * from './state/index'; diff --git a/feature-libs/product-configurator/rulebased/core/model/configurator.model.ts b/feature-libs/product-configurator/rulebased/core/model/configurator.model.ts new file mode 100644 index 00000000000..0f941cbb5f1 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/model/configurator.model.ts @@ -0,0 +1,179 @@ +import { CommonConfigurator } from '@spartacus/product-configurator/common'; + +export namespace Configurator { + export interface Attribute { + attrCode?: number; + name: string; + label?: string; + description?: string; + required?: boolean; + incomplete?: boolean; + uiType?: UiType; + dataType?: DataType; + quantity?: number; + values?: Value[]; + groupId?: string; + selectedSingleValue?: string; + userInput?: string; + isLineItem?: boolean; + maxlength?: number; + images?: Image[]; + numDecimalPlaces?: number; + numTotalLength?: number; + negativeAllowed?: boolean; + hasConflicts?: boolean; + retractTriggered?: boolean; + } + + export interface Value { + valueCode?: string; + name?: string; + valueDisplay?: string; + description?: string; + selected?: boolean; + quantity?: number; + productSystemId?: string; + isCommerceProduct?: boolean; + images?: Image[]; + } + + export interface Group { + attributes?: Attribute[]; + id?: string; + name?: string; + description?: string; + groupType?: GroupType; + configurable?: boolean; + complete?: boolean; + consistent?: boolean; + subGroups?: Group[]; + } + + export interface Configuration { + configId: string; + consistent?: boolean; + complete?: boolean; + totalNumberOfIssues?: number; + productCode?: string; + groups?: Group[]; + flatGroups?: Group[]; + priceSummary?: PriceSummary; + overview?: Overview; + owner?: CommonConfigurator.Owner; + nextOwner?: CommonConfigurator.Owner; + isCartEntryUpdateRequired?: boolean; + interactionState?: InteractionState; + } + + export interface InteractionState { + currentGroup?: string; + menuParentGroup?: string; + groupsVisited?: { + [id: string]: boolean; + }; + issueNavigationDone?: boolean; + } + + export interface Overview { + configId?: string; + totalNumberOfIssues?: number; + groups?: GroupOverview[]; + priceSummary?: PriceSummary; + productCode?: string; + } + + export interface GroupOverview { + id: string; + groupDescription?: string; + attributes?: AttributeOverview[]; + } + + export interface AttributeOverview { + attribute: string; + value: string; + } + + export interface PriceSummary { + basePrice?: PriceDetails; + currentTotal?: PriceDetails; + currentTotalSavings?: PriceSavingDetails; + selectedOptions?: PriceDetails; + } + + export interface PriceDetails { + currencyIso?: string; + formattedValue?: string; + value?: number; + } + + export interface PriceSavingDetails extends PriceDetails { + maxQuantity?: number; + minQuantity?: number; + } + + export interface AddToCartParameters { + userId: string; + cartId: string; + productCode: string; + quantity: number; + configId: string; + owner: CommonConfigurator.Owner; + } + + export interface UpdateConfigurationForCartEntryParameters { + userId?: string; + cartId?: string; + cartEntryNumber?: string; + configuration?: Configurator.Configuration; + } + + export interface Image { + type?: ImageType; + format?: ImageFormatType; + url?: string; + altText?: string; + galleryIndex?: number; + } + + export enum GroupType { + ATTRIBUTE_GROUP = 'AttributeGroup', + SUB_ITEM_GROUP = 'SubItemGroup', + CONFLICT_HEADER_GROUP = 'ConflictHeaderGroup', + CONFLICT_GROUP = 'ConflictGroup', + } + + export enum UiType { + NOT_IMPLEMENTED = 'not_implemented', + RADIOBUTTON = 'radioGroup', + CHECKBOX = 'checkBox', + CHECKBOXLIST = 'checkBoxList', + DROPDOWN = 'dropdown', + LISTBOX = 'listbox', + LISTBOX_MULTI = 'listboxmulti', + READ_ONLY = 'readonly', + STRING = 'string', + NUMERIC = 'numeric', + AUTO_COMPLETE_CUSTOM = 'input_autocomplete', + MULTI_SELECTION_IMAGE = 'multi_selection_image', + SINGLE_SELECTION_IMAGE = 'single_selection_image', + } + + export enum ImageFormatType { + VALUE_IMAGE = 'VALUE_IMAGE', + ATTRIBUTE_IMAGE = 'ATTRIBUTE_IMAGE', + } + + export enum ImageType { + PRIMARY = 'PRIMARY', + GALLERY = 'GALLERY', + } + + export enum DataType { + INPUT_STRING = 'String', + INPUT_NUMBER = 'Number', + USER_SELECTION_QTY_ATTRIBUTE_LEVEL = 'UserSelectionWithAttributeQuantity', + USER_SELECTION_QTY_VALUE_LEVEL = 'UserSelectionWithValueQuantity', + USER_SELECTION_NO_QTY = 'UserSelectionWithoutQuantity', + NOT_IMPLEMENTED = 'not_implemented', + } +} diff --git a/feature-libs/product-configurator/rulebased/core/model/index.ts b/feature-libs/product-configurator/rulebased/core/model/index.ts new file mode 100644 index 00000000000..174e948487e --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/model/index.ts @@ -0,0 +1 @@ +export * from './configurator.model'; diff --git a/feature-libs/product-configurator/rulebased/core/rulebased-configurator-core.module.ts b/feature-libs/product-configurator/rulebased/core/rulebased-configurator-core.module.ts new file mode 100644 index 00000000000..f58d1cb4790 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/rulebased-configurator-core.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { RulebasedConfiguratorConnector } from './connectors/rulebased-configurator.connector'; +import { RulebasedConfiguratorStateModule } from './state/rulebased-configurator-state.module'; + +/** + * Exposes the rulebased configurator core entities. + * Explicit providing of connector because otherwise lazy loading does not work + */ +@NgModule({ + imports: [RulebasedConfiguratorStateModule], + providers: [RulebasedConfiguratorConnector], +}) +export class RulebasedConfiguratorCoreModule {} diff --git a/feature-libs/product-configurator/rulebased/core/state/actions/configurator-cart.action.spec.ts b/feature-libs/product-configurator/rulebased/core/state/actions/configurator-cart.action.spec.ts new file mode 100644 index 00000000000..168a2965fd3 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/actions/configurator-cart.action.spec.ts @@ -0,0 +1,94 @@ +import { Type } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { MULTI_CART_DATA, StateUtils } from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { Configurator } from '../../model/configurator.model'; +import { CONFIGURATOR_DATA } from '../configurator-state'; +import * as ConfiguratorActions from './configurator-cart.action'; + +const PRODUCT_CODE = 'CONF_LAPTOP'; +const CONFIG_ID = '15468-5464-9852-54682'; + +const CONFIGURATION: Configurator.Configuration = { + productCode: PRODUCT_CODE, + configId: CONFIG_ID, + owner: { id: PRODUCT_CODE, type: CommonConfigurator.OwnerType.PRODUCT }, +}; + +describe('ConfiguratorCartActions', () => { + let configuratorUtils: CommonConfiguratorUtilsService; + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({}).compileComponents(); + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + configuratorUtils.setOwnerKey(CONFIGURATION.owner); + }) + ); + + describe('SetNextOwnerCartEntry', () => { + const cartEntryNo = '3'; + it('should carry expected meta data', () => { + const action = new ConfiguratorActions.SetNextOwnerCartEntry({ + configuration: CONFIGURATION, + cartEntryNo: cartEntryNo, + }); + + expect({ ...action }).toEqual({ + type: ConfiguratorActions.SET_NEXT_OWNER_CART_ENTRY, + payload: { configuration: CONFIGURATION, cartEntryNo: cartEntryNo }, + meta: StateUtils.entitySuccessMeta( + CONFIGURATOR_DATA, + CONFIGURATION.owner.key + ), + }); + }); + }); + + describe('UpdateCartEntry', () => { + const params: Configurator.UpdateConfigurationForCartEntryParameters = { + configuration: CONFIGURATION, + }; + it('should carry expected meta data', () => { + const action = new ConfiguratorActions.UpdateCartEntry(params); + + expect({ ...action }).toEqual({ + type: ConfiguratorActions.UPDATE_CART_ENTRY, + payload: params, + + meta: StateUtils.entityProcessesIncrementMeta( + MULTI_CART_DATA, + params.cartId + ), + }); + }); + }); + + describe('AddToCart', () => { + const params: Configurator.AddToCartParameters = { + userId: 'U', + cartId: '123', + productCode: PRODUCT_CODE, + quantity: 1, + configId: CONFIGURATION.configId, + owner: CONFIGURATION.owner, + }; + it('should carry expected meta data', () => { + const action = new ConfiguratorActions.AddToCart(params); + + expect({ ...action }).toEqual({ + type: ConfiguratorActions.ADD_TO_CART, + payload: params, + + meta: StateUtils.entityProcessesIncrementMeta( + MULTI_CART_DATA, + params.cartId + ), + }); + }); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/core/state/actions/configurator-cart.action.ts b/feature-libs/product-configurator/rulebased/core/state/actions/configurator-cart.action.ts new file mode 100644 index 00000000000..47a62ad2ca1 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/actions/configurator-cart.action.ts @@ -0,0 +1,118 @@ +import { Action } from '@ngrx/store'; +import { MULTI_CART_DATA, StateUtils } from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Configurator } from '../../model/configurator.model'; +import { CONFIGURATOR_DATA } from '../configurator-state'; + +export const READ_CART_ENTRY_CONFIGURATION = + '[Configurator] Read Cart Entry Configuration'; +export const READ_CART_ENTRY_CONFIGURATION_SUCCESS = + '[Configurator] Read Cart Entry Configuration Success'; +export const READ_CART_ENTRY_CONFIGURATION_FAIL = + '[Configurator] Read Cart Entry Configuration Fail'; +export const READ_ORDER_ENTRY_CONFIGURATION = + '[Configurator] Read Order Entry Configuration'; +export const READ_ORDER_ENTRY_CONFIGURATION_SUCCESS = + '[Configurator] Read Order Entry Configuration Success'; +export const READ_ORDER_ENTRY_CONFIGURATION_FAIL = + '[Configurator] Read Order Entry Configuration Fail'; + +export const ADD_TO_CART = '[Configurator] Add to cart'; +export const UPDATE_CART_ENTRY = '[Configurator] Update cart entry'; +export const UPDATE_CART_ENTRY_SUCCESS = + '[Configurator] Update cart entry success'; + +export const ADD_NEXT_OWNER = '[Configurator] Add next owner'; +export const SET_NEXT_OWNER_CART_ENTRY = + '[Configurator] Set next owner cart entry'; + +export class ReadCartEntryConfiguration extends StateUtils.EntityLoadAction { + readonly type = READ_CART_ENTRY_CONFIGURATION; + constructor( + public payload: CommonConfigurator.ReadConfigurationFromCartEntryParameters + ) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} + +export class ReadCartEntryConfigurationSuccess extends StateUtils.EntitySuccessAction { + readonly type = READ_CART_ENTRY_CONFIGURATION_SUCCESS; + constructor(public payload: Configurator.Configuration) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} + +export class ReadCartEntryConfigurationFail extends StateUtils.EntityFailAction { + readonly type = READ_CART_ENTRY_CONFIGURATION_FAIL; + constructor(public payload: { ownerKey: string; error: any }) { + super(CONFIGURATOR_DATA, payload.ownerKey, payload.error); + } +} + +export class ReadOrderEntryConfiguration extends StateUtils.EntityLoadAction { + readonly type = READ_ORDER_ENTRY_CONFIGURATION; + constructor( + public payload: CommonConfigurator.ReadConfigurationFromOrderEntryParameters + ) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} + +export class ReadOrderEntryConfigurationSuccess extends StateUtils.EntitySuccessAction { + readonly type = READ_ORDER_ENTRY_CONFIGURATION_SUCCESS; + constructor(public payload: Configurator.Configuration) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} + +export class ReadOrderEntryConfigurationFail extends StateUtils.EntityFailAction { + readonly type = READ_ORDER_ENTRY_CONFIGURATION_FAIL; + constructor(public payload: { ownerKey: string; error: any }) { + super(CONFIGURATOR_DATA, payload.ownerKey, payload.error); + } +} + +export class AddToCart extends StateUtils.EntityProcessesIncrementAction { + readonly type = ADD_TO_CART; + constructor(public payload: Configurator.AddToCartParameters) { + super(MULTI_CART_DATA, payload.cartId); + } +} + +export class UpdateCartEntry extends StateUtils.EntityProcessesIncrementAction { + readonly type = UPDATE_CART_ENTRY; + constructor( + public payload: Configurator.UpdateConfigurationForCartEntryParameters + ) { + super(MULTI_CART_DATA, payload.cartId); + } +} + +export class AddNextOwner implements Action { + readonly type = ADD_NEXT_OWNER; + constructor(public payload: { ownerKey: string; cartEntryNo: string }) {} +} + +export class SetNextOwnerCartEntry extends StateUtils.EntitySuccessAction { + readonly type = SET_NEXT_OWNER_CART_ENTRY; + + constructor( + public payload: { + configuration: Configurator.Configuration; + cartEntryNo: string; + } + ) { + super(CONFIGURATOR_DATA, payload.configuration.owner.key); + } +} + +export type ConfiguratorCartAction = + | AddNextOwner + | SetNextOwnerCartEntry + | ReadCartEntryConfiguration + | ReadCartEntryConfigurationSuccess + | ReadCartEntryConfigurationFail + | ReadOrderEntryConfiguration + | ReadOrderEntryConfigurationSuccess + | ReadOrderEntryConfigurationFail + | UpdateCartEntry; diff --git a/feature-libs/product-configurator/rulebased/core/state/actions/configurator-group.actions.ts b/feature-libs/product-configurator/rulebased/core/state/actions/configurator-group.actions.ts new file mode 100644 index 00000000000..e1b5d4f9529 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/actions/configurator-group.actions.ts @@ -0,0 +1,2 @@ +export * from './configurator-cart.action'; +export * from './configurator.action'; diff --git a/feature-libs/product-configurator/rulebased/core/state/actions/configurator.action.spec.ts b/feature-libs/product-configurator/rulebased/core/state/actions/configurator.action.spec.ts new file mode 100644 index 00000000000..e378e3b040c --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/actions/configurator.action.spec.ts @@ -0,0 +1,166 @@ +import { Type } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { StateUtils } from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { Configurator } from '../../model/configurator.model'; +import { CONFIGURATOR_DATA } from '../configurator-state'; +import * as ConfiguratorActions from './configurator.action'; + +const PRODUCT_CODE = 'CONF_LAPTOP'; +const CONFIG_ID = '15468-5464-9852-54682'; +const GROUP_ID = 'GROUP1'; +const CONFIGURATION: Configurator.Configuration = { + productCode: PRODUCT_CODE, + configId: CONFIG_ID, + owner: { id: PRODUCT_CODE, type: CommonConfigurator.OwnerType.PRODUCT }, +}; + +describe('ConfiguratorActions', () => { + let configuratorUtils: CommonConfiguratorUtilsService; + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({}).compileComponents(); + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + configuratorUtils.setOwnerKey(CONFIGURATION.owner); + }) + ); + it('should provide create action with proper type', () => { + const createAction = new ConfiguratorActions.CreateConfiguration( + CONFIGURATION.owner + ); + expect(createAction.type).toBe(ConfiguratorActions.CREATE_CONFIGURATION); + }); + + it('should provide create action that carries productCode as a payload', () => { + const createAction = new ConfiguratorActions.CreateConfiguration( + CONFIGURATION.owner + ); + expect(createAction.payload.id).toBe(PRODUCT_CODE); + }); + + describe('ReadConfiguration Actions', () => { + describe('ReadConfiguration', () => { + it('Should create the action', () => { + const action = new ConfiguratorActions.ReadConfiguration({ + configuration: CONFIGURATION, + groupId: GROUP_ID, + }); + expect({ ...action }).toEqual({ + type: ConfiguratorActions.READ_CONFIGURATION, + payload: { + configuration: CONFIGURATION, + groupId: GROUP_ID, + }, + meta: StateUtils.entityLoadMeta( + CONFIGURATOR_DATA, + CONFIGURATION.owner.key + ), + }); + }); + }); + + describe('ReadConfigurationFail', () => { + it('Should create the action', () => { + const error = 'anError'; + const action = new ConfiguratorActions.ReadConfigurationFail({ + ownerKey: PRODUCT_CODE, + error: error, + }); + expect({ ...action }).toEqual({ + type: ConfiguratorActions.READ_CONFIGURATION_FAIL, + payload: { + ownerKey: PRODUCT_CODE, + error: error, + }, + meta: StateUtils.entityFailMeta( + CONFIGURATOR_DATA, + PRODUCT_CODE, + error + ), + }); + }); + }); + + describe('ReadConfigurationSuccess', () => { + it('Should create the action', () => { + const action = new ConfiguratorActions.ReadConfigurationSuccess( + CONFIGURATION + ); + expect({ ...action }).toEqual({ + type: ConfiguratorActions.READ_CONFIGURATION_SUCCESS, + payload: CONFIGURATION, + meta: StateUtils.entitySuccessMeta( + CONFIGURATOR_DATA, + CONFIGURATION.owner.key + ), + }); + }); + }); + }); + + describe('UpdateConfiguration Actions', () => { + describe('UpdateConfiguration', () => { + it('Should create the action', () => { + const action = new ConfiguratorActions.UpdateConfiguration( + CONFIGURATION + ); + + expect({ ...action }).toEqual({ + type: ConfiguratorActions.UPDATE_CONFIGURATION, + payload: CONFIGURATION, + meta: { + entityType: CONFIGURATOR_DATA, + entityId: CONFIGURATION.owner.key, + loader: { load: true }, + processesCountDiff: 1, + }, + }); + }); + }); + + describe('UpdateConfigurationFail', () => { + it('Should create the action', () => { + const error = 'anError'; + const action = new ConfiguratorActions.UpdateConfigurationFail({ + configuration: CONFIGURATION, + error: error, + }); + + expect({ ...action }).toEqual({ + type: ConfiguratorActions.UPDATE_CONFIGURATION_FAIL, + payload: { + configuration: CONFIGURATION, + error: error, + }, + meta: { + entityType: CONFIGURATOR_DATA, + entityId: CONFIGURATION.owner.key, + loader: { error: error }, + processesCountDiff: -1, + }, + }); + }); + }); + + describe('UpdateConfigurationSuccess', () => { + it('Should create the action', () => { + const action = new ConfiguratorActions.UpdateConfigurationSuccess( + CONFIGURATION + ); + expect({ ...action }).toEqual({ + type: ConfiguratorActions.UPDATE_CONFIGURATION_SUCCESS, + payload: CONFIGURATION, + meta: StateUtils.entityProcessesDecrementMeta( + CONFIGURATOR_DATA, + CONFIGURATION.owner.key + ), + }); + }); + }); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/core/state/actions/configurator.action.ts b/feature-libs/product-configurator/rulebased/core/state/actions/configurator.action.ts new file mode 100644 index 00000000000..4089bed94ff --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/actions/configurator.action.ts @@ -0,0 +1,281 @@ +import { StateUtils } from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Configurator } from '../../model/configurator.model'; +import { CONFIGURATOR_DATA } from '../configurator-state'; + +export const CREATE_CONFIGURATION = '[Configurator] Create Configuration'; +export const CREATE_CONFIGURATION_FAIL = + '[Configurator] Create Configuration Fail'; +export const CREATE_CONFIGURATION_SUCCESS = + '[Configurator] Create Configuration Sucess'; +export const READ_CONFIGURATION = '[Configurator] Read Configuration'; +export const READ_CONFIGURATION_FAIL = '[Configurator] Read Configuration Fail'; +export const READ_CONFIGURATION_SUCCESS = + '[Configurator] Read Configuration Sucess'; + +export const UPDATE_CONFIGURATION = '[Configurator] Update Configuration'; +export const UPDATE_CONFIGURATION_FAIL = + '[Configurator] Update Configuration Fail'; +export const UPDATE_CONFIGURATION_SUCCESS = + '[Configurator] Update Configuration Success'; + +export const UPDATE_CONFIGURATION_FINALIZE_SUCCESS = + '[Configurator] Update Configuration finalize success'; +export const UPDATE_CONFIGURATION_FINALIZE_FAIL = + '[Configurator] Update Configuration finalize fail'; +export const CHANGE_GROUP = '[Configurator] Change group'; +export const CHANGE_GROUP_FINALIZE = '[Configurator] Change group finalize'; + +export const REMOVE_CONFIGURATION = '[Configurator] Remove configuration'; + +export const UPDATE_PRICE_SUMMARY = + '[Configurator] Update Configuration Summary Price'; +export const UPDATE_PRICE_SUMMARY_FAIL = + '[Configurator] Update Configuration Price Summary fail'; +export const UPDATE_PRICE_SUMMARY_SUCCESS = + '[Configurator] Update Configuration Price Summary success'; + +export const GET_CONFIGURATION_OVERVIEW = + '[Configurator] Get Configuration Overview'; +export const GET_CONFIGURATION_OVERVIEW_FAIL = + '[Configurator] Get Configuration Overview fail'; +export const GET_CONFIGURATION_OVERVIEW_SUCCESS = + '[Configurator] Get Configuration Overview success'; + +export const SET_INTERACTION_STATE = '[Configurator] Set interaction state'; +export const SET_CURRENT_GROUP = '[Configurator] Set current group to State'; +export const SET_MENU_PARENT_GROUP = + '[Configurator] Set current parent group for menu to State'; + +export const SET_GROUPS_VISITED = '[Configurator] Set groups to visited'; + +export class CreateConfiguration extends StateUtils.EntityLoadAction { + readonly type = CREATE_CONFIGURATION; + constructor(public payload: CommonConfigurator.Owner) { + super(CONFIGURATOR_DATA, payload.key); + } +} + +export class CreateConfigurationFail extends StateUtils.EntityFailAction { + readonly type = CREATE_CONFIGURATION_FAIL; + constructor( + public payload: { + ownerKey: string; + error: any; + } + ) { + super(CONFIGURATOR_DATA, payload.ownerKey, payload.error); + } +} + +export class CreateConfigurationSuccess extends StateUtils.EntitySuccessAction { + readonly type = CREATE_CONFIGURATION_SUCCESS; + constructor(public payload: Configurator.Configuration) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} + +export class ReadConfiguration extends StateUtils.EntityLoadAction { + readonly type = READ_CONFIGURATION; + constructor( + public payload: { + configuration: Configurator.Configuration; + groupId: string; + } + ) { + super(CONFIGURATOR_DATA, payload.configuration.owner.key); + } +} + +export class ReadConfigurationFail extends StateUtils.EntityFailAction { + readonly type = READ_CONFIGURATION_FAIL; + constructor(public payload: { ownerKey: string; error: any }) { + super(CONFIGURATOR_DATA, payload.ownerKey, payload.error); + } +} + +export class ReadConfigurationSuccess extends StateUtils.EntitySuccessAction { + readonly type = READ_CONFIGURATION_SUCCESS; + constructor(public payload: Configurator.Configuration) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} + +export class UpdateConfiguration extends StateUtils.EntityProcessesIncrementAction { + readonly type = UPDATE_CONFIGURATION; + constructor(public payload: Configurator.Configuration) { + super(CONFIGURATOR_DATA, payload.owner.key); + this.meta.loader = { + load: true, + }; + } +} + +export class UpdateConfigurationFail extends StateUtils.EntityProcessesDecrementAction { + readonly type = UPDATE_CONFIGURATION_FAIL; + constructor( + public payload: { configuration: Configurator.Configuration; error: any } + ) { + super(CONFIGURATOR_DATA, payload.configuration.owner.key); + this.meta.loader = { + error: payload.error, + }; + } +} + +export class UpdateConfigurationSuccess extends StateUtils.EntityProcessesDecrementAction { + readonly type = UPDATE_CONFIGURATION_SUCCESS; + constructor(public payload: Configurator.Configuration) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} + +export class UpdateConfigurationFinalizeSuccess extends StateUtils.EntitySuccessAction { + readonly type = UPDATE_CONFIGURATION_FINALIZE_SUCCESS; + constructor(public payload: Configurator.Configuration) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} + +export class UpdateConfigurationFinalizeFail extends StateUtils.EntitySuccessAction { + readonly type = UPDATE_CONFIGURATION_FINALIZE_FAIL; + constructor(public payload: Configurator.Configuration) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} + +export class UpdatePriceSummary extends StateUtils.EntityLoadAction { + readonly type = UPDATE_PRICE_SUMMARY; + constructor(public payload: Configurator.Configuration) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} +export class UpdatePriceSummaryFail extends StateUtils.EntityFailAction { + readonly type = UPDATE_PRICE_SUMMARY_FAIL; + constructor(public payload: { ownerKey: string; error: any }) { + super(CONFIGURATOR_DATA, payload.ownerKey, payload.error); + } +} + +export class UpdatePriceSummarySuccess extends StateUtils.EntitySuccessAction { + readonly type = UPDATE_PRICE_SUMMARY_SUCCESS; + constructor(public payload: Configurator.Configuration) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} + +export class ChangeGroup extends StateUtils.EntityLoadAction { + readonly type = CHANGE_GROUP; + constructor( + public payload: { + configuration: Configurator.Configuration; + groupId: string; + parentGroupId: string; + } + ) { + super(CONFIGURATOR_DATA, payload.configuration.owner.key); + } +} + +export class ChangeGroupFinalize extends StateUtils.EntityLoadAction { + readonly type = CHANGE_GROUP_FINALIZE; + constructor(public payload: Configurator.Configuration) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} + +export class RemoveConfiguration extends StateUtils.EntityLoaderResetAction { + readonly type = REMOVE_CONFIGURATION; + constructor(public payload: { ownerKey: string | string[] }) { + super(CONFIGURATOR_DATA, payload.ownerKey); + } +} + +export class GetConfigurationOverview extends StateUtils.EntityLoadAction { + readonly type = GET_CONFIGURATION_OVERVIEW; + constructor(public payload: Configurator.Configuration) { + super(CONFIGURATOR_DATA, payload.owner.key); + } +} + +export class GetConfigurationOverviewFail extends StateUtils.EntityFailAction { + readonly type = GET_CONFIGURATION_OVERVIEW_FAIL; + constructor(public payload: { ownerKey: string; error: any }) { + super(CONFIGURATOR_DATA, payload.ownerKey, payload.error); + } +} + +export class GetConfigurationOverviewSuccess extends StateUtils.EntitySuccessAction { + readonly type = GET_CONFIGURATION_OVERVIEW_SUCCESS; + constructor( + public payload: { ownerKey: string; overview: Configurator.Overview } + ) { + super(CONFIGURATOR_DATA, payload.ownerKey); + } +} + +export class SetInteractionState extends StateUtils.EntitySuccessAction { + readonly type = SET_INTERACTION_STATE; + + constructor( + public payload: { + entityKey: string | string[]; + interactionState: Configurator.InteractionState; + } + ) { + super(CONFIGURATOR_DATA, payload.entityKey, payload.interactionState); + } +} + +export class SetCurrentGroup extends StateUtils.EntitySuccessAction { + readonly type = SET_CURRENT_GROUP; + + constructor( + public payload: { entityKey: string | string[]; currentGroup: string } + ) { + super(CONFIGURATOR_DATA, payload.entityKey, payload.currentGroup); + } +} + +export class SetMenuParentGroup extends StateUtils.EntitySuccessAction { + readonly type = SET_MENU_PARENT_GROUP; + + constructor( + public payload: { entityKey: string | string[]; menuParentGroup: string } + ) { + super(CONFIGURATOR_DATA, payload.entityKey, payload.menuParentGroup); + } +} + +export class SetGroupsVisited extends StateUtils.EntitySuccessAction { + readonly type = SET_GROUPS_VISITED; + constructor(public payload: { entityKey: string; visitedGroups: string[] }) { + super(CONFIGURATOR_DATA, payload.entityKey, payload.visitedGroups); + } +} + +export type ConfiguratorAction = + | CreateConfiguration + | CreateConfigurationFail + | CreateConfigurationSuccess + | ReadConfiguration + | ReadConfigurationFail + | ReadConfigurationSuccess + | UpdateConfiguration + | UpdateConfigurationFail + | UpdateConfigurationSuccess + | UpdateConfigurationFinalizeFail + | UpdateConfigurationFinalizeSuccess + | UpdatePriceSummary + | UpdatePriceSummaryFail + | UpdatePriceSummarySuccess + | ChangeGroup + | ChangeGroupFinalize + | GetConfigurationOverview + | GetConfigurationOverviewFail + | GetConfigurationOverviewSuccess + | RemoveConfiguration + | SetInteractionState + | SetMenuParentGroup + | SetCurrentGroup + | SetGroupsVisited; diff --git a/feature-libs/product-configurator/rulebased/core/state/actions/index.ts b/feature-libs/product-configurator/rulebased/core/state/actions/index.ts new file mode 100644 index 00000000000..806fe90128b --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/actions/index.ts @@ -0,0 +1,2 @@ +import * as ConfiguratorActions from './configurator-group.actions'; +export { ConfiguratorActions }; diff --git a/feature-libs/product-configurator/rulebased/core/state/configurator-state.ts b/feature-libs/product-configurator/rulebased/core/state/configurator-state.ts new file mode 100644 index 00000000000..75f7476cbbb --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/configurator-state.ts @@ -0,0 +1,15 @@ +import { StateUtils } from '@spartacus/core'; +import { Configurator } from '../model/configurator.model'; + +export const CONFIGURATOR_FEATURE = 'productConfigurator'; +export const CONFIGURATOR_DATA = '[Configurator] Configuration Data'; + +export interface StateWithConfigurator { + [CONFIGURATOR_FEATURE]: ConfiguratorState; +} + +export interface ConfiguratorState { + configurations?: StateUtils.EntityProcessesLoaderState< + Configurator.Configuration + >; +} diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts new file mode 100644 index 00000000000..7648d3494df --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.spec.ts @@ -0,0 +1,589 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { normalizeHttpError } from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { cold, hot } from 'jasmine-marbles'; +import { Observable, of, throwError } from 'rxjs'; +import { ConfiguratorComponentTestUtilsService } from '../../../shared/testing/configurator-component-test-utils.service'; +import { RulebasedConfiguratorConnector } from '../../connectors/rulebased-configurator.connector'; +import { ConfiguratorUtilsService } from '../../facade/utils/configurator-utils.service'; +import { Configurator } from '../../model/configurator.model'; +import { ConfiguratorActions } from '../actions/index'; +import { + CONFIGURATOR_FEATURE, + StateWithConfigurator, +} from '../configurator-state'; +import * as fromConfigurationReducers from '../reducers/index'; +import * as fromEffects from './configurator-basic.effect'; + +const productCode = 'CONF_LAPTOP'; +const configId = '1234-56-7890'; +const groupId = 'GROUP-1'; + +const errorResponse: HttpErrorResponse = new HttpErrorResponse({ + error: 'notFound', + status: 404, +}); +const owner: CommonConfigurator.Owner = { + type: CommonConfigurator.OwnerType.PRODUCT, + id: productCode, + key: 'product/CONF_LAPTOP', +}; + +const group: Configurator.Group = { + id: groupId, + attributes: [{ name: 'attrName' }], + subGroups: [], +}; + +const groupWithSubGroup: Configurator.Group = { + id: groupId, + attributes: [ + { + name: 'attrName', + images: [{ url: 'imageAttr' }], + values: [{ name: 'val', images: [{ url: 'imageVal' }] }], + }, + ], + subGroups: [group], +}; +const productConfiguration: Configurator.Configuration = { + configId: 'a', + productCode: productCode, + owner: owner, + complete: true, + consistent: true, + overview: { + groups: [ + { + id: 'a', + groupDescription: 'a', + attributes: [ + { + attribute: 'a', + value: 'A', + }, + ], + }, + ], + }, + groups: [group, groupWithSubGroup], + flatGroups: [group], + priceSummary: {}, +}; +ConfiguratorComponentTestUtilsService.freezeProductConfiguration( + productConfiguration +); + +describe('ConfiguratorEffect', () => { + let createMock: jasmine.Spy; + let readMock: jasmine.Spy; + let updateConfigurationMock: jasmine.Spy; + let readPriceSummaryMock: jasmine.Spy; + let overviewMock: jasmine.Spy; + let configEffects: fromEffects.ConfiguratorBasicEffects; + + let store: Store; + + let actions$: Observable; + + beforeEach(() => { + createMock = jasmine.createSpy().and.returnValue(of(productConfiguration)); + updateConfigurationMock = jasmine + .createSpy() + .and.returnValue(of(productConfiguration)); + readPriceSummaryMock = jasmine + .createSpy() + .and.returnValue(of(productConfiguration)); + readMock = jasmine.createSpy().and.returnValue(of(productConfiguration)); + overviewMock = jasmine + .createSpy() + .and.returnValue(of(productConfiguration.overview)); + + class MockConnector { + createConfiguration = createMock; + readConfiguration = readMock; + updateConfiguration = updateConfigurationMock; + readPriceSummary = readPriceSummaryMock; + getConfigurationOverview = overviewMock; + } + + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + StoreModule.forRoot({}), + StoreModule.forFeature( + CONFIGURATOR_FEATURE, + fromConfigurationReducers.getConfiguratorReducers() + ), + ], + + providers: [ + fromEffects.ConfiguratorBasicEffects, + provideMockActions(() => actions$), + { + provide: RulebasedConfiguratorConnector, + useClass: MockConnector, + }, + { + provide: ConfiguratorUtilsService, + useClass: ConfiguratorUtilsService, + }, + ], + }); + + configEffects = TestBed.inject( + fromEffects.ConfiguratorBasicEffects as Type< + fromEffects.ConfiguratorBasicEffects + > + ); + store = TestBed.inject(Store as Type>); + }); + + it('should provide configuration effects', () => { + expect(configEffects).toBeTruthy(); + }); + + it('should emit a success action with content for an action of type createConfiguration', () => { + const action = new ConfiguratorActions.CreateConfiguration( + productConfiguration.owner + ); + + const completion = new ConfiguratorActions.CreateConfigurationSuccess( + productConfiguration + ); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: completion }); + + expect(configEffects.createConfiguration$).toBeObservable(expected); + }); + + it('must not emit anything in case source action is not covered, createConfiguration', () => { + const actionNotCovered = new ConfiguratorActions.CreateConfigurationSuccess( + productConfiguration + ); + actions$ = hot('-a', { a: actionNotCovered }); + const expected = cold('-'); + + expect(configEffects.createConfiguration$).toBeObservable(expected); + }); + + it('should emit a fail action in case something goes wrong', () => { + createMock.and.returnValue(throwError(errorResponse)); + + const action = new ConfiguratorActions.CreateConfiguration( + productConfiguration.owner + ); + + const completionFailure = new ConfiguratorActions.CreateConfigurationFail({ + ownerKey: productConfiguration.owner.key, + error: normalizeHttpError(errorResponse), + }); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: completionFailure }); + + expect(configEffects.createConfiguration$).toBeObservable(expected); + }); + + describe('Effect readConfiguration', () => { + it('should emit a success action with content in case connector call goes fine', () => { + const payloadInput: Configurator.Configuration = { + configId: configId, + owner: owner, + }; + const action = new ConfiguratorActions.ReadConfiguration({ + configuration: payloadInput, + groupId: '', + }); + + const completion = new ConfiguratorActions.ReadConfigurationSuccess( + productConfiguration + ); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: completion }); + + expect(configEffects.readConfiguration$).toBeObservable(expected); + }); + + it('should emit a fail action in case connector raises an error', () => { + readMock.and.returnValue(throwError(errorResponse)); + const action = new ConfiguratorActions.ReadConfiguration({ + configuration: productConfiguration, + groupId: '', + }); + + const readConfigurationFailAction = new ConfiguratorActions.ReadConfigurationFail( + { + ownerKey: productConfiguration.owner.key, + error: normalizeHttpError(errorResponse), + } + ); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: readConfigurationFailAction }); + + expect(configEffects.readConfiguration$).toBeObservable(expected); + }); + + it('must not emit anything in case source action is not covered', () => { + const payloadInput = { configId: configId, owner: owner }; + const action = new ConfiguratorActions.ReadConfigurationSuccess( + payloadInput + ); + actions$ = hot('-a', { a: action }); + const expected = cold('-'); + expect(configEffects.readConfiguration$).toBeObservable(expected); + }); + }); + + describe('Effect getOverview', () => { + it('should emit a success action with content in case connector call goes well', () => { + const payloadInput: Configurator.Configuration = { + configId: configId, + owner: owner, + }; + const action = new ConfiguratorActions.GetConfigurationOverview( + payloadInput + ); + + const overviewSuccessAction = new ConfiguratorActions.GetConfigurationOverviewSuccess( + { + ownerKey: owner.key, + overview: productConfiguration.overview, + } + ); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: overviewSuccessAction }); + + expect(configEffects.getOverview$).toBeObservable(expected); + }); + + it('should emit a fail action in case something goes wrong', () => { + overviewMock.and.returnValue(throwError(errorResponse)); + const overviewAction = new ConfiguratorActions.GetConfigurationOverview( + productConfiguration + ); + + const failAction = new ConfiguratorActions.GetConfigurationOverviewFail({ + ownerKey: productConfiguration.owner.key, + error: normalizeHttpError(errorResponse), + }); + actions$ = hot('-a', { a: overviewAction }); + const expected = cold('-b', { b: failAction }); + + expect(configEffects.getOverview$).toBeObservable(expected); + }); + }); + + describe('Effect updateConfiguration', () => { + it('should emit a success action with content for an action of type updateConfiguration', () => { + const payloadInput = productConfiguration; + const action = new ConfiguratorActions.UpdateConfiguration(payloadInput); + + const completion = new ConfiguratorActions.UpdateConfigurationSuccess( + productConfiguration + ); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: completion }); + + expect(configEffects.updateConfiguration$).toBeObservable(expected); + }); + + it('must not emit anything in case source action is not covered', () => { + const payloadInput = productConfiguration; + const actionNotCovered = new ConfiguratorActions.UpdateConfigurationSuccess( + payloadInput + ); + actions$ = hot('-a', { a: actionNotCovered }); + const expected = cold('-'); + expect(configEffects.updateConfiguration$).toBeObservable(expected); + }); + + it('should emit a fail action in case something goes wrong', () => { + updateConfigurationMock.and.returnValue(throwError(errorResponse)); + const payloadInput = productConfiguration; + const action = new ConfiguratorActions.UpdateConfiguration(payloadInput); + + const failAction = new ConfiguratorActions.UpdateConfigurationFail({ + configuration: productConfiguration, + error: normalizeHttpError(errorResponse), + }); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: failAction }); + + expect(configEffects.updateConfiguration$).toBeObservable(expected); + }); + }); + + describe('Effect updatePriceSummary', () => { + it('should emit a price summary success action in case call is successfull', () => { + const payloadInput = productConfiguration; + const updatePriceSummaryAction = new ConfiguratorActions.UpdatePriceSummary( + payloadInput + ); + + const updatePriceSummarySuccessAction = new ConfiguratorActions.UpdatePriceSummarySuccess( + productConfiguration + ); + actions$ = hot('-a', { a: updatePriceSummaryAction }); + const expected = cold('-b', { b: updatePriceSummarySuccessAction }); + + expect(configEffects.updatePriceSummary$).toBeObservable(expected); + }); + + it('should emit a fail action in case something goes wrong', () => { + readPriceSummaryMock.and.returnValue(throwError(errorResponse)); + const payloadInput = productConfiguration; + const updatePriceSummaryAction = new ConfiguratorActions.UpdatePriceSummary( + payloadInput + ); + + const failAction = new ConfiguratorActions.UpdatePriceSummaryFail({ + ownerKey: productConfiguration.owner.key, + error: normalizeHttpError(errorResponse), + }); + actions$ = hot('-a', { a: updatePriceSummaryAction }); + const expected = cold('-b', { b: failAction }); + + expect(configEffects.updatePriceSummary$).toBeObservable(expected); + }); + }); + + describe('Effect updateConfigurationSuccess', () => { + it('should raise UpdateConfigurationFinalize, UpdatePrices and ChangeGroup in case no changes are pending', () => { + const payloadInput = productConfiguration; + const action = new ConfiguratorActions.UpdateConfigurationSuccess( + payloadInput + ); + const finalizeSuccess = new ConfiguratorActions.UpdateConfigurationFinalizeSuccess( + productConfiguration + ); + const updatePrices = new ConfiguratorActions.UpdatePriceSummary( + productConfiguration + ); + const changeGroup = new ConfiguratorActions.ChangeGroup({ + configuration: productConfiguration, + groupId: groupId, + parentGroupId: undefined, + }); + + actions$ = hot('-a', { a: action }); + const expected = cold('-(bcd)', { + b: finalizeSuccess, + c: updatePrices, + d: changeGroup, + }); + expect(configEffects.updateConfigurationSuccess$).toBeObservable( + expected + ); + }); + + it('should not raise ChangeGroup in case current group does not change', () => { + store.dispatch( + new ConfiguratorActions.SetCurrentGroup({ + entityKey: productConfiguration.owner.key, + currentGroup: productConfiguration.groups[0].id, + }) + ); + const payloadInput = productConfiguration; + const action = new ConfiguratorActions.UpdateConfigurationSuccess( + payloadInput + ); + const finalizeSuccess = new ConfiguratorActions.UpdateConfigurationFinalizeSuccess( + productConfiguration + ); + const updatePrices = new ConfiguratorActions.UpdatePriceSummary( + productConfiguration + ); + + actions$ = hot('-a', { a: action }); + const expected = cold('-(bc)', { + b: finalizeSuccess, + c: updatePrices, + }); + expect(configEffects.updateConfigurationSuccess$).toBeObservable( + expected + ); + }); + }); + describe('Effect updateConfigurationFail', () => { + it('should raise UpdateConfigurationFinalizeFail on UpdateConfigurationFail in case no changes are pending', () => { + const payloadInput = productConfiguration; + const action = new ConfiguratorActions.UpdateConfigurationFail({ + configuration: payloadInput, + error: undefined, + }); + const completion = new ConfiguratorActions.UpdateConfigurationFinalizeFail( + productConfiguration + ); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: completion }); + expect(configEffects.updateConfigurationFail$).toBeObservable(expected); + }); + it('must not emit anything in case of UpdateConfigurationSuccess', () => { + const payloadInput = productConfiguration; + const action = new ConfiguratorActions.UpdateConfigurationSuccess( + payloadInput + ); + actions$ = hot('-a', { a: action }); + + configEffects.updateConfigurationFail$.subscribe((emitted) => + fail(emitted) + ); + // just to get rid of the SPEC_HAS_NO_EXPECTATIONS message. + // The actual test is done in the subscribe part + expect(true).toBeTruthy(); + }); + }); + describe('Effect handleErrorOnUpdate', () => { + it('should emit ReadConfiguration on UpdateConfigurationFinalizeFail', () => { + const payloadInput = productConfiguration; + const action = new ConfiguratorActions.UpdateConfigurationFinalizeFail( + payloadInput + ); + const completion = new ConfiguratorActions.ReadConfiguration({ + configuration: productConfiguration, + groupId: undefined, + }); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: completion }); + expect(configEffects.handleErrorOnUpdate$).toBeObservable(expected); + }); + }); + describe('Effect groupChange', () => { + it('should emit ReadConfigurationSuccess and SetCurrentGroup/SetParentGroup on ChangeGroup in case no changes are pending', () => { + const payloadInput: Configurator.Configuration = { + configId: configId, + productCode: productCode, + owner: owner, + }; + const action = new ConfiguratorActions.ChangeGroup({ + configuration: payloadInput, + groupId: groupId, + parentGroupId: null, + }); + const readConfigurationSuccess = new ConfiguratorActions.ReadConfigurationSuccess( + productConfiguration + ); + const setCurrentGroup = new ConfiguratorActions.SetCurrentGroup({ + entityKey: productConfiguration.owner.key, + currentGroup: groupId, + }); + const setMenuParentGroup = new ConfiguratorActions.SetMenuParentGroup({ + entityKey: productConfiguration.owner.key, + menuParentGroup: null, + }); + + actions$ = hot('-a', { a: action }); + + const expected = cold('-(bcd)', { + b: setCurrentGroup, + c: setMenuParentGroup, + d: readConfigurationSuccess, + }); + expect(configEffects.groupChange$).toBeObservable(expected); + }); + + it('should emit ReadConfigurationFail in case read call is not successful', () => { + readMock.and.returnValue(throwError(errorResponse)); + const payloadInput: Configurator.Configuration = { + configId: configId, + productCode: productCode, + owner: owner, + }; + const action = new ConfiguratorActions.ChangeGroup({ + configuration: payloadInput, + groupId: groupId, + parentGroupId: null, + }); + const readConfigurationFail = new ConfiguratorActions.ReadConfigurationFail( + { + ownerKey: productConfiguration.owner.key, + error: normalizeHttpError(errorResponse), + } + ); + + actions$ = hot('-a', { a: action }); + + const expected = cold('-b', { + b: readConfigurationFail, + }); + expect(configEffects.groupChange$).toBeObservable(expected); + }); + }); + + describe('getGroupWithAttributes', () => { + it('should find group in single level config', () => { + expect( + configEffects.getGroupWithAttributes(productConfiguration.groups) + ).toBe(groupId); + }); + + it('should find group in multi level config', () => { + const groups: Configurator.Group[] = [ + { + attributes: [], + subGroups: [ + { + attributes: [], + subGroups: [], + }, + { + attributes: [], + subGroups: [], + }, + ], + }, + { + attributes: [], + subGroups: productConfiguration.groups, + }, + { + attributes: [], + subGroups: [ + { + attributes: [], + subGroups: [], + }, + ], + }, + ]; + expect(configEffects.getGroupWithAttributes(groups)).toBe(groupId); + }); + + it('should find no group in multi level config in case no attributes exist at all', () => { + const groups: Configurator.Group[] = [ + { + attributes: [], + subGroups: [ + { + attributes: [], + subGroups: [], + }, + { + attributes: [], + subGroups: [], + }, + ], + }, + { + attributes: [], + subGroups: [{ attributes: [], subGroups: [] }], + }, + { + attributes: [], + subGroups: [ + { + attributes: [], + subGroups: [], + }, + ], + }, + ]; + expect(configEffects.getGroupWithAttributes(groups)).toBeUndefined(); + }); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts new file mode 100644 index 00000000000..2173f4c1e1f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-basic.effect.ts @@ -0,0 +1,371 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { select, Store } from '@ngrx/store'; +import { normalizeHttpError } from '@spartacus/core'; +import { CommonConfiguratorUtilsService } from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { + catchError, + filter, + map, + mergeMap, + switchMap, + switchMapTo, + take, +} from 'rxjs/operators'; +import { RulebasedConfiguratorConnector } from '../../connectors/rulebased-configurator.connector'; +import { ConfiguratorGroupStatusService } from '../../facade/configurator-group-status.service'; +import { ConfiguratorUtilsService } from '../../facade/utils/configurator-utils.service'; +import { Configurator } from '../../model/configurator.model'; +import { ConfiguratorActions } from '../actions/index'; +import { StateWithConfigurator } from '../configurator-state'; +import { ConfiguratorSelectors } from '../selectors/index'; + +@Injectable() +/** + * Common configurator effects, used for complex configurators like variant configurator + * and CPQ + */ +export class ConfiguratorBasicEffects { + @Effect() + createConfiguration$: Observable< + | ConfiguratorActions.CreateConfigurationSuccess + | ConfiguratorActions.CreateConfigurationFail + > = this.actions$.pipe( + ofType(ConfiguratorActions.CREATE_CONFIGURATION), + mergeMap((action: ConfiguratorActions.CreateConfiguration) => { + return this.configuratorCommonsConnector + .createConfiguration(action.payload) + .pipe( + switchMap((configuration: Configurator.Configuration) => { + this.store.dispatch( + new ConfiguratorActions.UpdatePriceSummary(configuration) + ); + + return [ + new ConfiguratorActions.CreateConfigurationSuccess(configuration), + ]; + }), + catchError((error) => [ + new ConfiguratorActions.CreateConfigurationFail({ + ownerKey: action.payload.key, + error: normalizeHttpError(error), + }), + ]) + ); + }) + ); + + @Effect() + readConfiguration$: Observable< + | ConfiguratorActions.ReadConfigurationSuccess + | ConfiguratorActions.ReadConfigurationFail + > = this.actions$.pipe( + ofType(ConfiguratorActions.READ_CONFIGURATION), + + mergeMap((action: ConfiguratorActions.ReadConfiguration) => { + return this.configuratorCommonsConnector + .readConfiguration( + action.payload.configuration.configId, + action.payload.groupId, + action.payload.configuration.owner + ) + .pipe( + switchMap((configuration: Configurator.Configuration) => { + return [ + new ConfiguratorActions.ReadConfigurationSuccess(configuration), + ]; + }), + catchError((error) => [ + new ConfiguratorActions.ReadConfigurationFail({ + ownerKey: action.payload.configuration.owner.key, + error: normalizeHttpError(error), + }), + ]) + ); + }) + ); + + @Effect() + updateConfiguration$: Observable< + | ConfiguratorActions.UpdateConfigurationSuccess + | ConfiguratorActions.UpdateConfigurationFail + > = this.actions$.pipe( + ofType(ConfiguratorActions.UPDATE_CONFIGURATION), + map((action: ConfiguratorActions.UpdateConfiguration) => action.payload), + //mergeMap here as we need to process each update + //(which only sends one changed attribute at a time), + //so we must not cancel inner emissions + mergeMap((payload: Configurator.Configuration) => { + return this.configuratorCommonsConnector + .updateConfiguration(payload) + .pipe( + map((configuration: Configurator.Configuration) => { + return new ConfiguratorActions.UpdateConfigurationSuccess( + configuration + ); + }), + catchError((error) => { + const errorPayload = normalizeHttpError(error); + return [ + new ConfiguratorActions.UpdateConfigurationFail({ + configuration: payload, + error: errorPayload, + }), + ]; + }) + ); + }) + ); + + @Effect() + updatePriceSummary$: Observable< + | ConfiguratorActions.UpdatePriceSummarySuccess + | ConfiguratorActions.UpdatePriceSummaryFail + > = this.actions$.pipe( + ofType(ConfiguratorActions.UPDATE_PRICE_SUMMARY), + map( + (action: { type: string; payload?: Configurator.Configuration }) => + action.payload + ), + mergeMap((payload) => { + return this.configuratorCommonsConnector.readPriceSummary(payload).pipe( + map((configuration: Configurator.Configuration) => { + return new ConfiguratorActions.UpdatePriceSummarySuccess( + configuration + ); + }), + catchError((error) => { + const errorPayload = normalizeHttpError(error); + return [ + new ConfiguratorActions.UpdatePriceSummaryFail({ + ownerKey: payload.owner.key, + error: errorPayload, + }), + ]; + }) + ); + }) + ); + + @Effect() + getOverview$: Observable< + | ConfiguratorActions.GetConfigurationOverviewSuccess + | ConfiguratorActions.GetConfigurationOverviewFail + > = this.actions$.pipe( + ofType(ConfiguratorActions.GET_CONFIGURATION_OVERVIEW), + map( + (action: ConfiguratorActions.GetConfigurationOverview) => action.payload + ), + mergeMap((payload) => { + return this.configuratorCommonsConnector + .getConfigurationOverview(payload) + .pipe( + map((overview: Configurator.Overview) => { + return new ConfiguratorActions.GetConfigurationOverviewSuccess({ + ownerKey: payload.owner.key, + overview: overview, + }); + }), + catchError((error) => { + const errorPayload = normalizeHttpError(error); + return [ + new ConfiguratorActions.GetConfigurationOverviewFail({ + ownerKey: payload.owner.key, + error: errorPayload, + }), + ]; + }) + ); + }) + ); + + @Effect() + updateConfigurationSuccess$: Observable< + | ConfiguratorActions.UpdateConfigurationFinalizeSuccess + | ConfiguratorActions.UpdatePriceSummary + | ConfiguratorActions.ChangeGroup + > = this.actions$.pipe( + ofType(ConfiguratorActions.UPDATE_CONFIGURATION_SUCCESS), + map( + (action: ConfiguratorActions.UpdateConfigurationSuccess) => action.payload + ), + mergeMap((payload: Configurator.Configuration) => { + return this.store.pipe( + select(ConfiguratorSelectors.hasPendingChanges(payload.owner.key)), + take(1), + filter((hasPendingChanges) => hasPendingChanges === false), + switchMapTo( + this.store.pipe( + select(ConfiguratorSelectors.getCurrentGroup(payload.owner.key)), + take(1), + map((currentGroupId) => { + const groupIdFromPayload = this.getGroupWithAttributes( + payload.groups + ); + const parentGroupFromPayload = this.configuratorGroupUtilsService.getParentGroup( + payload.groups, + this.configuratorGroupUtilsService.getGroupById( + payload.groups, + groupIdFromPayload + ), + null + ); + return { + currentGroupId, + groupIdFromPayload, + parentGroupFromPayload, + }; + }), + switchMap((container) => { + //changeGroup because in cases where a queue of updates exists with a group navigation in between, + //we need to ensure that the last update determines the current group. + const updateFinalizeSuccessAction = new ConfiguratorActions.UpdateConfigurationFinalizeSuccess( + payload + ); + const updatePriceSummaryAction = new ConfiguratorActions.UpdatePriceSummary( + payload + ); + return container.currentGroupId === container.groupIdFromPayload + ? [updateFinalizeSuccessAction, updatePriceSummaryAction] + : [ + updateFinalizeSuccessAction, + updatePriceSummaryAction, + new ConfiguratorActions.ChangeGroup({ + configuration: payload, + groupId: container.groupIdFromPayload, + parentGroupId: container.parentGroupFromPayload?.id, + }), + ]; + }) + ) + ) + ); + }) + ); + + @Effect() + updateConfigurationFail$: Observable< + ConfiguratorActions.UpdateConfigurationFinalizeFail + > = this.actions$.pipe( + ofType(ConfiguratorActions.UPDATE_CONFIGURATION_FAIL), + map( + (action: ConfiguratorActions.UpdateConfigurationFail) => action.payload + ), + mergeMap((payload) => { + return this.store.pipe( + select( + ConfiguratorSelectors.hasPendingChanges( + payload.configuration.owner.key + ) + ), + take(1), + filter((hasPendingChanges) => hasPendingChanges === false), + map( + () => + new ConfiguratorActions.UpdateConfigurationFinalizeFail( + payload.configuration + ) + ) + ); + }) + ); + + @Effect() + handleErrorOnUpdate$: Observable< + ConfiguratorActions.ReadConfiguration + > = this.actions$.pipe( + ofType(ConfiguratorActions.UPDATE_CONFIGURATION_FINALIZE_FAIL), + map( + (action: ConfiguratorActions.UpdateConfigurationFinalizeFail) => + action.payload + ), + map( + (payload) => + new ConfiguratorActions.ReadConfiguration({ + configuration: payload, + groupId: undefined, + }) + ) + ); + + @Effect() + groupChange$: Observable< + | ConfiguratorActions.SetCurrentGroup + | ConfiguratorActions.SetMenuParentGroup + | ConfiguratorActions.ReadConfigurationFail + | ConfiguratorActions.ReadConfigurationSuccess + > = this.actions$.pipe( + ofType(ConfiguratorActions.CHANGE_GROUP), + switchMap((action: ConfiguratorActions.ChangeGroup) => { + return this.store.pipe( + select( + ConfiguratorSelectors.hasPendingChanges( + action.payload.configuration.owner.key + ) + ), + take(1), + filter((hasPendingChanges) => hasPendingChanges === false), + switchMap(() => { + return this.configuratorCommonsConnector + .readConfiguration( + action.payload.configuration.configId, + action.payload.groupId, + action.payload.configuration.owner + ) + .pipe( + switchMap((configuration: Configurator.Configuration) => { + return [ + new ConfiguratorActions.SetCurrentGroup({ + entityKey: action.payload.configuration.owner.key, + currentGroup: action.payload.groupId, + }), + new ConfiguratorActions.SetMenuParentGroup({ + entityKey: action.payload.configuration.owner.key, + menuParentGroup: action.payload.parentGroupId, + }), + new ConfiguratorActions.ReadConfigurationSuccess( + configuration + ), + ]; + }), + catchError((error) => [ + new ConfiguratorActions.ReadConfigurationFail({ + ownerKey: action.payload.configuration.owner.key, + error: normalizeHttpError(error), + }), + ]) + ); + }) + ); + }) + ); + + getGroupWithAttributes(groups: Configurator.Group[]): string { + const groupWithAttributes: Configurator.Group = groups + .filter((currentGroup) => currentGroup.attributes.length > 0) + .pop(); + let id: string; + if (groupWithAttributes) { + id = groupWithAttributes.id; + } else { + id = groups + .filter((currentGroup) => currentGroup.subGroups.length > 0) + .flatMap((currentGroup) => + this.getGroupWithAttributes(currentGroup.subGroups) + ) + .filter((groupId) => groupId) //Filter undefined strings + .pop(); + } + return id; + } + + constructor( + protected actions$: Actions, + protected configuratorCommonsConnector: RulebasedConfiguratorConnector, + protected commonConfigUtilsService: CommonConfiguratorUtilsService, + protected configuratorGroupUtilsService: ConfiguratorUtilsService, + protected configuratorGroupStatusService: ConfiguratorGroupStatusService, + protected store: Store + ) {} +} diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-cart.effect.spec.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-cart.effect.spec.ts new file mode 100644 index 00000000000..dc3b1e772fb --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-cart.effect.spec.ts @@ -0,0 +1,404 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import * as ngrxStore from '@ngrx/store'; +import { StoreModule } from '@ngrx/store'; +import { + CartActions, + CartModification, + normalizeHttpError, +} from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { cold, hot } from 'jasmine-marbles'; +import { Observable, of, throwError } from 'rxjs'; +import { ConfiguratorComponentTestUtilsService } from '../../../shared/testing/configurator-component-test-utils.service'; +import { RulebasedConfiguratorConnector } from '../../connectors/rulebased-configurator.connector'; +import { ConfiguratorUtilsService } from '../../facade/utils/configurator-utils.service'; +import { Configurator } from '../../model/configurator.model'; +import { ConfiguratorActions } from '../actions/index'; +import { CONFIGURATOR_FEATURE } from '../configurator-state'; +import * as fromConfigurationReducers from '../reducers/index'; +import * as fromEffects from './configurator-cart.effect'; + +const productCode = 'CONF_LAPTOP'; +const configId = '1234-56-7890'; +const groupId = 'GROUP-1'; +const cartId = 'CART-1234'; +const cartEntryNumber = '1'; +const userId = 'theUser'; +const quantity = 1; +const entryNumber = 47; +const errorResponse: HttpErrorResponse = new HttpErrorResponse({ + error: 'notFound', + status: 404, +}); +const owner: CommonConfigurator.Owner = { + type: CommonConfigurator.OwnerType.PRODUCT, + id: productCode, + key: 'product/CONF_LAPTOP', +}; + +const productConfiguration: Configurator.Configuration = { + configId: 'a', + productCode: productCode, + owner: owner, + complete: true, + consistent: true, + overview: { + groups: [ + { + id: 'a', + groupDescription: 'a', + attributes: [ + { + attribute: 'a', + value: 'A', + }, + ], + }, + ], + }, + groups: [{ id: groupId, attributes: [{ name: 'attrName' }], subGroups: [] }], +}; +ConfiguratorComponentTestUtilsService.freezeProductConfiguration( + productConfiguration +); + +let payloadInputUpdateConfiguration: Configurator.UpdateConfigurationForCartEntryParameters; + +const cartModification: CartModification = { + quantity: 1, + quantityAdded: 1, + deliveryModeChanged: true, + entry: { + product: { code: productCode }, + quantity: 1, + entryNumber: entryNumber, + }, + statusCode: '', + statusMessage: '', +}; + +describe('ConfiguratorCartEffect', () => { + let addToCartMock: jasmine.Spy; + let updateCartEntryMock: jasmine.Spy; + let readConfigurationForCartEntryMock: jasmine.Spy; + let readConfigurationForOrderEntryMock: jasmine.Spy; + let configCartEffects: fromEffects.ConfiguratorCartEffects; + let configuratorUtils: CommonConfiguratorUtilsService; + + let actions$: Observable; + + beforeEach(() => { + addToCartMock = jasmine.createSpy().and.returnValue(of(cartModification)); + updateCartEntryMock = jasmine + .createSpy() + .and.returnValue(of(cartModification)); + readConfigurationForCartEntryMock = jasmine + .createSpy() + .and.returnValue(of(productConfiguration)); + readConfigurationForOrderEntryMock = jasmine + .createSpy() + .and.returnValue(of(productConfiguration)); + + class MockConnector { + addToCart = addToCartMock; + updateConfigurationForCartEntry = updateCartEntryMock; + readConfigurationForCartEntry = readConfigurationForCartEntryMock; + readConfigurationForOrderEntry = readConfigurationForOrderEntryMock; + } + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + StoreModule.forRoot({}), + StoreModule.forFeature( + CONFIGURATOR_FEATURE, + fromConfigurationReducers.getConfiguratorReducers() + ), + ], + + providers: [ + fromEffects.ConfiguratorCartEffects, + provideMockActions(() => actions$), + { + provide: RulebasedConfiguratorConnector, + useClass: MockConnector, + }, + { + provide: ConfiguratorUtilsService, + useClass: ConfiguratorUtilsService, + }, + ], + }); + + configCartEffects = TestBed.inject( + fromEffects.ConfiguratorCartEffects as Type< + fromEffects.ConfiguratorCartEffects + > + ); + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + + payloadInputUpdateConfiguration = { + userId: userId, + cartId: cartId, + configuration: productConfiguration, + cartEntryNumber: entryNumber.toString(), + }; + }); + + it('should provide configuration effects', () => { + expect(configCartEffects).toBeTruthy(); + }); + + describe('Effect addOwner', () => { + it('should emit 2 result actions', () => { + spyOnProperty(ngrxStore, 'select').and.returnValue(() => () => + of(productConfiguration) + ); + const addOwnerAction = new ConfiguratorActions.AddNextOwner({ + ownerKey: productConfiguration.owner.key, + cartEntryNo: cartEntryNumber, + }); + + const setNextOwnerAction = new ConfiguratorActions.SetNextOwnerCartEntry({ + configuration: productConfiguration, + cartEntryNo: cartEntryNumber, + }); + const newCartEntryOwner: CommonConfigurator.Owner = { + type: CommonConfigurator.OwnerType.CART_ENTRY, + id: cartEntryNumber, + }; + configuratorUtils.setOwnerKey(newCartEntryOwner); + const setInteractionStateAction = new ConfiguratorActions.SetInteractionState( + { + entityKey: newCartEntryOwner.key, + interactionState: productConfiguration.interactionState, + } + ); + actions$ = hot('-a', { a: addOwnerAction }); + const expected = cold('-(bc)', { + b: setNextOwnerAction, + c: setInteractionStateAction, + }); + + expect(configCartEffects.addOwner$).toBeObservable(expected); + }); + }); + + describe('Effect readConfigurationForCartEntry', () => { + it('should emit a success action with content for an action of type readConfigurationForCartEntry', () => { + const readFromCartEntry: CommonConfigurator.ReadConfigurationFromCartEntryParameters = { + owner: owner, + }; + const action = new ConfiguratorActions.ReadCartEntryConfiguration( + readFromCartEntry + ); + + const readCartEntrySuccessAction = new ConfiguratorActions.ReadCartEntryConfigurationSuccess( + productConfiguration + ); + + const updatePriceAction = new ConfiguratorActions.UpdatePriceSummary( + productConfiguration + ); + + actions$ = hot('-a', { a: action }); + const expected = cold('-(bc)', { + b: readCartEntrySuccessAction, + c: updatePriceAction, + }); + + expect(configCartEffects.readConfigurationForCartEntry$).toBeObservable( + expected + ); + }); + + it('should emit a fail action if something goes wrong', () => { + readConfigurationForCartEntryMock.and.returnValue( + throwError(errorResponse) + ); + const readFromCartEntry: CommonConfigurator.ReadConfigurationFromCartEntryParameters = { + owner: owner, + }; + const action = new ConfiguratorActions.ReadCartEntryConfiguration( + readFromCartEntry + ); + + const completion = new ConfiguratorActions.ReadCartEntryConfigurationFail( + { + ownerKey: productConfiguration.owner.key, + error: normalizeHttpError(errorResponse), + } + ); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: completion }); + + expect(configCartEffects.readConfigurationForCartEntry$).toBeObservable( + expected + ); + }); + }); + + describe('Effect readConfigurationForOrderEntry', () => { + it('should emit a success action with content in case call is successful', () => { + const readFromOrderEntry: CommonConfigurator.ReadConfigurationFromCartEntryParameters = { + owner: owner, + }; + const action = new ConfiguratorActions.ReadOrderEntryConfiguration( + readFromOrderEntry + ); + + const readOrderEntrySuccessAction = new ConfiguratorActions.ReadOrderEntryConfigurationSuccess( + productConfiguration + ); + + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { + b: readOrderEntrySuccessAction, + }); + + expect(configCartEffects.readConfigurationForOrderEntry$).toBeObservable( + expected + ); + }); + + it('should emit a fail action if something goes wrong', () => { + readConfigurationForOrderEntryMock.and.returnValue( + throwError(errorResponse) + ); + const readFromOrderEntry: CommonConfigurator.ReadConfigurationFromOrderEntryParameters = { + owner: owner, + }; + const action = new ConfiguratorActions.ReadOrderEntryConfiguration( + readFromOrderEntry + ); + + const completion = new ConfiguratorActions.ReadOrderEntryConfigurationFail( + { + ownerKey: productConfiguration.owner.key, + error: normalizeHttpError(errorResponse), + } + ); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: completion }); + + expect(configCartEffects.readConfigurationForOrderEntry$).toBeObservable( + expected + ); + }); + }); + + describe('Effect addToCart', () => { + it('should emit AddToCartSuccess, AddOwner on addToCart in case no changes are pending', () => { + const payloadInput: Configurator.AddToCartParameters = { + userId: userId, + cartId: cartId, + productCode: productCode, + quantity: quantity, + configId: configId, + owner: owner, + }; + const action = new ConfiguratorActions.AddToCart(payloadInput); + const cartAddEntrySuccess = new CartActions.CartAddEntrySuccess({ + ...cartModification, + userId: userId, + cartId: cartId, + productCode: payloadInput.productCode, + quantity: cartModification.quantity, + deliveryModeChanged: cartModification.deliveryModeChanged, + entry: cartModification.entry, + quantityAdded: cartModification.quantityAdded, + statusCode: cartModification.statusCode, + statusMessage: cartModification.statusMessage, + }); + + const addNextOwner = new ConfiguratorActions.AddNextOwner({ + ownerKey: owner.key, + cartEntryNo: '' + entryNumber, + }); + actions$ = hot('-a', { a: action }); + const expected = cold('-(cd)', { + c: addNextOwner, + d: cartAddEntrySuccess, + }); + expect(configCartEffects.addToCart$).toBeObservable(expected); + }); + + it('should emit AddToCartFail in case add to cart call is not successful', () => { + addToCartMock.and.returnValue(throwError(errorResponse)); + const payloadInput: Configurator.AddToCartParameters = { + userId: userId, + cartId: cartId, + productCode: productCode, + quantity: quantity, + configId: configId, + owner: owner, + }; + const action = new ConfiguratorActions.AddToCart(payloadInput); + const cartAddEntryFail = new CartActions.CartAddEntryFail({ + userId, + cartId, + productCode, + quantity, + error: normalizeHttpError(errorResponse), + }); + + actions$ = hot('-a', { a: action }); + + const expected = cold('-b', { + b: cartAddEntryFail, + }); + expect(configCartEffects.addToCart$).toBeObservable(expected); + }); + }); + + describe('Effect updateCartEntry', () => { + it('should emit updateCartEntrySuccess on updateCartEntry in case no changes are pending', () => { + const action = new ConfiguratorActions.UpdateCartEntry( + payloadInputUpdateConfiguration + ); + const cartUpdateEntrySuccess = new CartActions.CartUpdateEntrySuccess({ + ...cartModification, + userId: userId, + cartId: cartId, + entryNumber: cartModification.entry.entryNumber.toString(), + quantity: cartModification.quantity, + }); + + actions$ = hot('-a', { a: action }); + const expected = cold('-d)', { + d: cartUpdateEntrySuccess, + }); + expect(configCartEffects.updateCartEntry$).toBeObservable(expected); + }); + + it('should emit AddToCartFail in case update cart entry call is not successful', () => { + updateCartEntryMock.and.returnValue(throwError(errorResponse)); + + const action = new ConfiguratorActions.UpdateCartEntry( + payloadInputUpdateConfiguration + ); + const cartAddEntryFail = new CartActions.CartUpdateEntryFail({ + userId, + cartId, + entryNumber: entryNumber.toString(), + quantity: 1, + error: normalizeHttpError(errorResponse), + }); + + actions$ = hot('-a', { a: action }); + + const expected = cold('-b', { + b: cartAddEntryFail, + }); + expect(configCartEffects.updateCartEntry$).toBeObservable(expected); + }); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-cart.effect.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-cart.effect.ts new file mode 100644 index 00000000000..735c48556c8 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-cart.effect.ts @@ -0,0 +1,203 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { select, Store } from '@ngrx/store'; +import { + CartActions, + CartModification, + normalizeHttpError, +} from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { Observable, of } from 'rxjs'; +import { catchError, map, switchMap, take } from 'rxjs/operators'; +import { RulebasedConfiguratorConnector } from '../../connectors/rulebased-configurator.connector'; +import { ConfiguratorUtilsService } from '../../facade/utils/configurator-utils.service'; +import { Configurator } from '../../model/configurator.model'; +import { ConfiguratorActions } from '../actions/index'; +import { StateWithConfigurator } from '../configurator-state'; +import { ConfiguratorSelectors } from '../selectors/index'; + +@Injectable() +/** + * Common configurator effects related to cart handling + */ +export class ConfiguratorCartEffects { + @Effect() + addToCart$: Observable< + | ConfiguratorActions.AddNextOwner + | CartActions.CartAddEntrySuccess + | CartActions.CartAddEntryFail + > = this.actions$.pipe( + ofType(ConfiguratorActions.ADD_TO_CART), + map((action: ConfiguratorActions.AddToCart) => action.payload), + switchMap((payload: Configurator.AddToCartParameters) => { + return this.configuratorCommonsConnector.addToCart(payload).pipe( + switchMap((entry: CartModification) => { + return [ + new ConfiguratorActions.AddNextOwner({ + ownerKey: payload.owner.key, + cartEntryNo: '' + entry.entry.entryNumber, + }), + new CartActions.CartAddEntrySuccess({ + ...entry, + userId: payload.userId, + cartId: payload.cartId, + productCode: payload.productCode, + quantity: entry.quantity, + deliveryModeChanged: entry.deliveryModeChanged, + entry: entry.entry, + quantityAdded: entry.quantityAdded, + statusCode: entry.statusCode, + statusMessage: entry.statusMessage, + }), + ]; + }), + catchError((error) => + of( + new CartActions.CartAddEntryFail({ + userId: payload.userId, + cartId: payload.cartId, + productCode: payload.productCode, + quantity: payload.quantity, + error: normalizeHttpError(error), + }) + ) + ) + ); + }) + ); + + @Effect() + updateCartEntry$: Observable< + CartActions.CartUpdateEntrySuccess | CartActions.CartUpdateEntryFail + > = this.actions$.pipe( + ofType(ConfiguratorActions.UPDATE_CART_ENTRY), + map((action: ConfiguratorActions.UpdateCartEntry) => action.payload), + switchMap( + (payload: Configurator.UpdateConfigurationForCartEntryParameters) => { + return this.configuratorCommonsConnector + .updateConfigurationForCartEntry(payload) + .pipe( + switchMap((entry: CartModification) => { + return [ + new CartActions.CartUpdateEntrySuccess({ + ...entry, + userId: payload.userId, + cartId: payload.cartId, + entryNumber: entry.entry.entryNumber.toString(), + quantity: entry.quantity, + }), + ]; + }), + catchError((error) => + of( + new CartActions.CartUpdateEntryFail({ + userId: payload.userId, + cartId: payload.cartId, + entryNumber: payload.cartEntryNumber, + quantity: 1, + error: normalizeHttpError(error), + }) + ) + ) + ); + } + ) + ); + + @Effect() + readConfigurationForCartEntry$: Observable< + | ConfiguratorActions.ReadCartEntryConfigurationSuccess + | ConfiguratorActions.UpdatePriceSummary + | ConfiguratorActions.ReadCartEntryConfigurationFail + > = this.actions$.pipe( + ofType(ConfiguratorActions.READ_CART_ENTRY_CONFIGURATION), + switchMap((action: ConfiguratorActions.ReadCartEntryConfiguration) => { + const parameters: CommonConfigurator.ReadConfigurationFromCartEntryParameters = + action.payload; + return this.configuratorCommonsConnector + .readConfigurationForCartEntry(parameters) + .pipe( + switchMap((result: Configurator.Configuration) => [ + new ConfiguratorActions.ReadCartEntryConfigurationSuccess(result), + new ConfiguratorActions.UpdatePriceSummary(result), + ]), + catchError((error) => [ + new ConfiguratorActions.ReadCartEntryConfigurationFail({ + ownerKey: action.payload.owner.key, + error: normalizeHttpError(error), + }), + ]) + ); + }) + ); + + @Effect() + readConfigurationForOrderEntry$: Observable< + | ConfiguratorActions.ReadOrderEntryConfigurationSuccess + | ConfiguratorActions.ReadOrderEntryConfigurationFail + > = this.actions$.pipe( + ofType(ConfiguratorActions.READ_ORDER_ENTRY_CONFIGURATION), + switchMap((action: ConfiguratorActions.ReadOrderEntryConfiguration) => { + const parameters: CommonConfigurator.ReadConfigurationFromOrderEntryParameters = + action.payload; + return this.configuratorCommonsConnector + .readConfigurationForOrderEntry(parameters) + .pipe( + switchMap((result: Configurator.Configuration) => [ + new ConfiguratorActions.ReadOrderEntryConfigurationSuccess(result), + ]), + catchError((error) => [ + new ConfiguratorActions.ReadOrderEntryConfigurationFail({ + ownerKey: action.payload.owner.key, + error: normalizeHttpError(error), + }), + ]) + ); + }) + ); + + @Effect() + addOwner$: Observable< + | ConfiguratorActions.SetNextOwnerCartEntry + | ConfiguratorActions.SetInteractionState + > = this.actions$.pipe( + ofType(ConfiguratorActions.ADD_NEXT_OWNER), + switchMap((action: ConfiguratorActions.AddNextOwner) => { + return this.store.pipe( + select( + ConfiguratorSelectors.getConfigurationFactory(action.payload.ownerKey) + ), + take(1), + switchMap((configuration) => { + const newOwner: CommonConfigurator.Owner = { + type: CommonConfigurator.OwnerType.CART_ENTRY, + id: action.payload.cartEntryNo, + }; + this.commonConfigUtilsService.setOwnerKey(newOwner); + + return [ + new ConfiguratorActions.SetNextOwnerCartEntry({ + configuration: configuration, + cartEntryNo: action.payload.cartEntryNo, + }), + new ConfiguratorActions.SetInteractionState({ + entityKey: newOwner.key, + interactionState: configuration.interactionState, + }), + ]; + }) + ); + }) + ); + + constructor( + protected actions$: Actions, + protected configuratorCommonsConnector: RulebasedConfiguratorConnector, + protected commonConfigUtilsService: CommonConfiguratorUtilsService, + protected configuratorGroupUtilsService: ConfiguratorUtilsService, + protected store: Store + ) {} +} diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-place-order-hook.effect.spec.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-place-order-hook.effect.spec.ts new file mode 100644 index 00000000000..cb6dd2d4c16 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-place-order-hook.effect.spec.ts @@ -0,0 +1,149 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { StoreModule } from '@ngrx/store'; +import { + ActiveCartService, + CheckoutActions, + OrderEntry, +} from '@spartacus/core'; +import { CommonConfiguratorUtilsService } from '@spartacus/product-configurator/common'; +import { cold, hot } from 'jasmine-marbles'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorActions } from '../actions/index'; +import { CONFIGURATOR_FEATURE } from '../configurator-state'; +import * as fromConfigurationReducers from '../reducers/index'; +import * as fromEffects from './configurator-place-order-hook.effect'; + +const cartEntryWOconfiguration: OrderEntry[] = [ + { + entryNumber: 1, + product: { + configurable: false, + }, + }, + { + entryNumber: 2, + product: { + configurable: false, + }, + }, +]; + +const cartEntryWithconfiguration: OrderEntry[] = [ + { + entryNumber: 1, + product: { + configurable: true, + configuratorType: 'CPQCONFIGURATOR', + }, + }, + { + entryNumber: 2, + product: { + configurable: false, + }, + }, + { + entryNumber: 3, + product: { + configurable: true, + configuratorType: 'CPQCONFIGURATOR', + }, + }, + { + entryNumber: 4, + product: { + configurable: true, + configuratorType: 'some other configurator', + }, + }, +]; +class MockActiveCartService { + getEntries(): void {} +} + +describe('ConfiguratorPlaceOrderHookEffects', () => { + let actions$: Observable; + let configPlaceOrderHookEffects: fromEffects.ConfiguratorPlaceOrderHookEffects; + let configuratorUtils: CommonConfiguratorUtilsService; + let activeCartService: ActiveCartService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature( + CONFIGURATOR_FEATURE, + fromConfigurationReducers.getConfiguratorReducers() + ), + ], + + providers: [ + fromEffects.ConfiguratorPlaceOrderHookEffects, + provideMockActions(() => actions$), + { + provide: ActiveCartService, + useClass: MockActiveCartService, + }, + ], + }); + + configPlaceOrderHookEffects = TestBed.inject( + fromEffects.ConfiguratorPlaceOrderHookEffects as Type< + fromEffects.ConfiguratorPlaceOrderHookEffects + > + ); + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + activeCartService = TestBed.inject( + ActiveCartService as Type + ); + spyOn(configuratorUtils, 'setOwnerKey').and.callThrough(); + }); + + it('should provide configuration place order hook effects', () => { + expect(configPlaceOrderHookEffects).toBeTruthy(); + }); + + it('should emit remove configuration when order is placed - cart contains configured products', () => { + spyOn(activeCartService, 'getEntries').and.returnValue( + of(cartEntryWithconfiguration) + ); + + const action = new CheckoutActions.PlaceOrder({ + cartId: '', + userId: '', + termsChecked: true, + }); + const completion = new ConfiguratorActions.RemoveConfiguration({ + ownerKey: ['cartEntry/1', 'cartEntry/3'], + }); + + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: completion }); + + expect(configPlaceOrderHookEffects.placeOrder$).toBeObservable(expected); + }); + + it('should emit remove configuration when order is placed - cart contains no configured products', () => { + spyOn(activeCartService, 'getEntries').and.returnValue( + of(cartEntryWOconfiguration) + ); + + const action = new CheckoutActions.PlaceOrder({ + cartId: '', + userId: '', + termsChecked: true, + }); + const completion = new ConfiguratorActions.RemoveConfiguration({ + ownerKey: [], + }); + + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: completion }); + + expect(configPlaceOrderHookEffects.placeOrder$).toBeObservable(expected); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/configurator-place-order-hook.effect.ts b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-place-order-hook.effect.ts new file mode 100644 index 00000000000..26a0b8bfda0 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/effects/configurator-place-order-hook.effect.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { ActiveCartService, CheckoutActions } from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { ConfiguratorActions } from '../actions/index'; + +@Injectable() +/** + * Effect used to hook into the place order action + */ +export class ConfiguratorPlaceOrderHookEffects { + @Effect() + placeOrder$: Observable< + ConfiguratorActions.RemoveConfiguration + > = this.actions$.pipe( + ofType(CheckoutActions.PLACE_ORDER), + map(() => { + const ownerKeys = []; + this.activeCartService + .getEntries() + .pipe(take(1)) + .subscribe((entries) => { + entries.forEach((entry) => { + if ( + !entry.product?.configurable || + entry.product?.configuratorType !== 'CPQCONFIGURATOR' + ) { + return; + } + + const owner: CommonConfigurator.Owner = { + type: CommonConfigurator.OwnerType.CART_ENTRY, + id: String(entry.entryNumber), + }; + this.commonConfigUtilsService.setOwnerKey(owner); + + ownerKeys.push(owner.key); + }); + }); + return new ConfiguratorActions.RemoveConfiguration({ + ownerKey: ownerKeys, + }); + }) + ); + + constructor( + protected actions$: Actions, + protected activeCartService: ActiveCartService, + protected commonConfigUtilsService: CommonConfiguratorUtilsService + ) {} +} diff --git a/feature-libs/product-configurator/rulebased/core/state/effects/index.ts b/feature-libs/product-configurator/rulebased/core/state/effects/index.ts new file mode 100644 index 00000000000..9b52543cbad --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/effects/index.ts @@ -0,0 +1,9 @@ +import { ConfiguratorBasicEffects } from './configurator-basic.effect'; +import { ConfiguratorCartEffects } from './configurator-cart.effect'; +import { ConfiguratorPlaceOrderHookEffects } from './configurator-place-order-hook.effect'; + +export const ConfiguratorEffects: any[] = [ + ConfiguratorBasicEffects, + ConfiguratorCartEffects, + ConfiguratorPlaceOrderHookEffects, +]; diff --git a/feature-libs/product-configurator/rulebased/core/state/index.ts b/feature-libs/product-configurator/rulebased/core/state/index.ts new file mode 100644 index 00000000000..b74dd8e2b36 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/index.ts @@ -0,0 +1,3 @@ +export * from './actions/index'; +export * from './configurator-state'; +export * from './selectors/index'; diff --git a/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.spec.ts b/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.spec.ts new file mode 100644 index 00000000000..bbe073855af --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.spec.ts @@ -0,0 +1,384 @@ +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Configurator } from '../../model/configurator.model'; +import { ConfiguratorActions } from '../actions/index'; +import * as StateReduce from './configurator.reducer'; + +const productCode = 'CONF_LAPTOP'; +const owner: CommonConfigurator.Owner = { + type: CommonConfigurator.OwnerType.PRODUCT, + id: productCode, +}; +const configuration: Configurator.Configuration = { + configId: 'ds', + productCode: productCode, + owner: owner, + groups: [ + { + id: 'firstGroup', + }, + { + id: 'secondGroup', + }, + ], + isCartEntryUpdateRequired: false, + interactionState: { + currentGroup: 'firstGroup', + groupsVisited: {}, + menuParentGroup: null, + issueNavigationDone: true, + }, +}; +const CURRENT_GROUP = 'currentGroupId'; +const PARENT_GROUP = 'parentGroupId'; +const PRODUCT_CODE = 'CONF_PRODUCT'; + +describe('Configurator reducer', () => { + describe('Undefined action', () => { + it('should return the default state', () => { + const { initialState } = StateReduce; + const action = {} as any; + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state).toEqual(initialState); + }); + }); + describe('CreateConfigurationSuccess action', () => { + it('should put configuration into the state', () => { + const action = new ConfiguratorActions.CreateConfigurationSuccess( + configuration + ); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state).toEqual(configuration); + expect(state.interactionState.currentGroup).toEqual( + configuration.groups[0].id + ); + }); + it('should take current group from flatGroups if current group in interaction state is undefined', () => { + const configurationWithoutCurrentGroup: Configurator.Configuration = { + owner: owner, + productCode: productCode, + configId: 'A', + overview: {}, + flatGroups: [ + { + id: 'flatFirstGroup', + }, + { + id: 'flatSecondGroup', + }, + ], + }; + const action = new ConfiguratorActions.CreateConfigurationSuccess( + configurationWithoutCurrentGroup + ); + const state = StateReduce.configuratorReducer(undefined, action); + expect(state.interactionState.currentGroup).toEqual('flatFirstGroup'); + }); + }); + describe('ReadCartEntryConfigurationSuccess action', () => { + it('should put configuration into the state', () => { + const action = new ConfiguratorActions.ReadCartEntryConfigurationSuccess( + configuration + ); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state).toEqual(configuration); + expect(state.interactionState.currentGroup).toEqual( + configuration.groups[0].id + ); + }); + }); + describe('ReadConfigurationSuccess action', () => { + it('should put configuration into the state', () => { + const action = new ConfiguratorActions.ReadConfigurationSuccess( + configuration + ); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state).toEqual(configuration); + expect(state.interactionState.currentGroup).toEqual( + configuration.groups[0].id + ); + }); + }); + describe('UpdateConfigurationSuccess action', () => { + it('should not put configuration into the state because first we need to check for pending changes', () => { + const { initialState } = StateReduce; + const action = new ConfiguratorActions.UpdateConfigurationSuccess( + configuration + ); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state).toEqual(initialState); + }); + }); + describe('UpdateConfigurationFail action', () => { + it('should not put configuration into the state', () => { + const { initialState } = StateReduce; + const action: ConfiguratorActions.ConfiguratorAction = new ConfiguratorActions.UpdateConfigurationFail( + { + configuration: configuration, + error: null, + } + ); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state).toEqual(initialState); + }); + }); + describe('UpdateConfiguration action', () => { + it('should not put configuration into the state because it is only triggering the update process', () => { + const { initialState } = StateReduce; + const action: ConfiguratorActions.ConfiguratorAction = new ConfiguratorActions.UpdateConfiguration( + configuration + ); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state).toEqual(initialState); + }); + }); + describe('UpdateConfigurationFinalizeSuccess action', () => { + it('should put configuration into the state', () => { + const action = new ConfiguratorActions.UpdateConfigurationFinalizeSuccess( + configuration + ); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state.owner).toEqual(configuration.owner); + expect(state.configId).toEqual(configuration.configId); + expect(state.productCode).toEqual(configuration.productCode); + }); + + it('should set attribute that states that a cart update is required', () => { + const action = new ConfiguratorActions.UpdateConfigurationFinalizeSuccess( + configuration + ); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state.isCartEntryUpdateRequired).toEqual(true); + }); + + it('should remove the overview facet in order to trigger a re-read later on', () => { + const action = new ConfiguratorActions.UpdateConfigurationFinalizeSuccess( + configuration + ); + const configurationWithOverview: Configurator.Configuration = { + configId: 'A', + overview: {}, + }; + const state = StateReduce.configuratorReducer( + configurationWithOverview, + action + ); + + expect(state.overview).toBeUndefined(); + }); + }); + + describe('UpdateCartEntry action', () => { + it('should set attribute that states that a cart update is not required anymore but an backend update is pending', () => { + const params: Configurator.UpdateConfigurationForCartEntryParameters = { + configuration: configuration, + }; + const action = new ConfiguratorActions.UpdateCartEntry(params); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state.isCartEntryUpdateRequired).toEqual(false); + }); + }); + + describe('RemoveConfiguration action', () => { + it('should set the initial state', () => { + const action1 = new ConfiguratorActions.ReadConfigurationSuccess( + configuration + ); + let state = StateReduce.configuratorReducer(undefined, action1); + + expect(state.configId).toEqual('ds'); + + const action2 = new ConfiguratorActions.RemoveConfiguration({ + ownerKey: configuration.productCode, + }); + state = StateReduce.configuratorReducer(undefined, action2); + + expect(state.configId).toEqual(''); + }); + }); + + describe('SetInteractionState action', () => { + it('interaction state should be set', () => { + const { initialState } = StateReduce; + + const state = StateReduce.configuratorReducer( + initialState, + new ConfiguratorActions.SetInteractionState({ + entityKey: PRODUCT_CODE, + interactionState: { + currentGroup: CURRENT_GROUP, + }, + }) + ); + + expect(state.interactionState.currentGroup).toEqual(CURRENT_GROUP); + }); + }); + + describe('SetCurrentGroup action', () => { + it('should change the current group', () => { + const { initialState } = StateReduce; + + const state = StateReduce.configuratorReducer( + initialState, + new ConfiguratorActions.SetCurrentGroup({ + entityKey: PRODUCT_CODE, + currentGroup: CURRENT_GROUP, + }) + ); + + expect(state.interactionState.currentGroup).toEqual(CURRENT_GROUP); + }); + }); + + describe('SetMenuParentGroup action', () => { + it('should change the parentGroup group', () => { + const { initialState } = StateReduce; + + const state = StateReduce.configuratorReducer( + initialState, + new ConfiguratorActions.SetMenuParentGroup({ + entityKey: PRODUCT_CODE, + menuParentGroup: PARENT_GROUP, + }) + ); + + expect(state.interactionState.menuParentGroup).toEqual(PARENT_GROUP); + }); + }); + + describe('Group Status reducers', () => { + it('should reduce Group Visited with initial state', () => { + const { initialState } = StateReduce; + + const action = new ConfiguratorActions.SetGroupsVisited({ + entityKey: PRODUCT_CODE, + visitedGroups: ['group1', 'group2', 'group3'], + }); + + const state = StateReduce.configuratorReducer(initialState, action); + + expect(state.interactionState.groupsVisited).toEqual({ + group1: true, + group2: true, + group3: true, + }); + }); + + it('should reduce Group Visited with existing state', () => { + const initialState = { + ...StateReduce.initialState, + interactionState: { + groupsVisited: { + group1: true, + group2: true, + group3: true, + }, + }, + }; + + const action = new ConfiguratorActions.SetGroupsVisited({ + entityKey: PRODUCT_CODE, + visitedGroups: ['group4'], + }); + + const state = StateReduce.configuratorReducer(initialState, action); + + expect(state.interactionState.groupsVisited).toEqual({ + group1: true, + group2: true, + group3: true, + group4: true, + }); + }); + }); + + describe('GetConfigurationOverviewSuccess action', () => { + it('should put configuration overview into the state', () => { + const priceSummary: Configurator.PriceSummary = {}; + const overview: Configurator.Overview = { priceSummary: priceSummary }; + const action = new ConfiguratorActions.GetConfigurationOverviewSuccess({ + ownerKey: configuration.owner.key, + overview: overview, + }); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state.overview).toEqual(overview); + expect(state.priceSummary).toBe(priceSummary); + }); + }); + + describe('GetConfigurationOverviewSuccess action', () => { + it('should put configuration overview into the state', () => { + const overview: Configurator.Overview = {}; + const action = new ConfiguratorActions.GetConfigurationOverviewSuccess({ + ownerKey: configuration.owner.key, + overview: overview, + }); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state.overview).toEqual(overview); + }); + + it('should copy price summary from OV to configuration', () => { + const priceSummary: Configurator.PriceSummary = {}; + const overview: Configurator.Overview = { priceSummary: priceSummary }; + const action = new ConfiguratorActions.GetConfigurationOverviewSuccess({ + ownerKey: configuration.owner.key, + overview: overview, + }); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state.priceSummary).toBe(priceSummary); + }); + }); + + describe('ReadOrderEntryConfigurationSuccess action', () => { + it('should put configuration overview into the state', () => { + const overview: Configurator.Overview = {}; + configuration.overview = overview; + const action = new ConfiguratorActions.ReadOrderEntryConfigurationSuccess( + configuration + ); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state.overview).toEqual(overview); + }); + + it('should copy price summary from OV to configuration', () => { + const priceSummary: Configurator.PriceSummary = {}; + const overview: Configurator.Overview = { priceSummary: priceSummary }; + configuration.overview = overview; + const action = new ConfiguratorActions.ReadOrderEntryConfigurationSuccess( + configuration + ); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state.priceSummary).toBe(priceSummary); + }); + }); + + describe('SetNextOwnerCartEntry action', () => { + it('should set next owner', () => { + const action = new ConfiguratorActions.SetNextOwnerCartEntry({ + configuration: configuration, + cartEntryNo: '1', + }); + const state = StateReduce.configuratorReducer(undefined, action); + + expect(state.nextOwner).toBeDefined(); + expect(state.nextOwner.type).toBe( + CommonConfigurator.OwnerType.CART_ENTRY + ); + }); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.ts b/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.ts new file mode 100644 index 00000000000..13bb8d521cd --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/reducers/configurator.reducer.ts @@ -0,0 +1,183 @@ +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Configurator } from '../../model/configurator.model'; +import { ConfiguratorActions } from '../actions/index'; + +export const initialState: Configurator.Configuration = { + configId: '', + interactionState: { + currentGroup: null, + groupsVisited: {}, + menuParentGroup: null, + }, +}; +export const initialStatePendingChanges = 0; + +export function configuratorReducer( + state = initialState, + action: + | ConfiguratorActions.ConfiguratorAction + | ConfiguratorActions.ConfiguratorCartAction +): Configurator.Configuration { + switch (action.type) { + case ConfiguratorActions.UPDATE_CONFIGURATION_FINALIZE_SUCCESS: { + const result: Configurator.Configuration = takeOverChanges(action, state); + result.isCartEntryUpdateRequired = true; + result.overview = undefined; + return result; + } + case ConfiguratorActions.UPDATE_CART_ENTRY: { + const result = { ...state }; + result.isCartEntryUpdateRequired = false; + return result; + } + case ConfiguratorActions.CREATE_CONFIGURATION_SUCCESS: + case ConfiguratorActions.READ_CONFIGURATION_SUCCESS: + case ConfiguratorActions.READ_CART_ENTRY_CONFIGURATION_SUCCESS: + case ConfiguratorActions.UPDATE_PRICE_SUMMARY_SUCCESS: { + return setInitialCurrentGroup(takeOverChanges(action, state)); + } + + case ConfiguratorActions.GET_CONFIGURATION_OVERVIEW_SUCCESS: { + const content = { ...action.payload.overview }; + + const result: Configurator.Configuration = { + ...state, + overview: content, + priceSummary: content.priceSummary, + interactionState: { + ...state.interactionState, + issueNavigationDone: false, + }, + }; + + return result; + } + case ConfiguratorActions.READ_ORDER_ENTRY_CONFIGURATION_SUCCESS: { + const configuration = { ...action.payload }; + + const result: Configurator.Configuration = { + ...state, + ...configuration, + priceSummary: configuration.overview.priceSummary, + }; + + return result; + } + case ConfiguratorActions.SET_NEXT_OWNER_CART_ENTRY: { + const content = { ...action.payload.configuration }; + content.nextOwner = { + type: CommonConfigurator.OwnerType.CART_ENTRY, + id: action.payload.cartEntryNo, + }; + const result = { + ...state, + ...content, + }; + + return result; + } + case ConfiguratorActions.SET_INTERACTION_STATE: { + const newInteractionState: Configurator.InteractionState = + action.payload.interactionState; + + return { + ...state, + interactionState: newInteractionState, + }; + } + case ConfiguratorActions.SET_CURRENT_GROUP: { + const newCurrentGroup: string = action.payload.currentGroup; + + return { + ...state, + interactionState: { + ...state.interactionState, + currentGroup: newCurrentGroup, + }, + }; + } + case ConfiguratorActions.SET_MENU_PARENT_GROUP: { + const newMenuParentGroup: string = action.payload.menuParentGroup; + + return { + ...state, + interactionState: { + ...state.interactionState, + menuParentGroup: newMenuParentGroup, + }, + }; + } + case ConfiguratorActions.SET_GROUPS_VISITED: { + const groupIds: string[] = action.payload.visitedGroups; + + const changedInteractionState: Configurator.InteractionState = { + groupsVisited: {}, + }; + + //Set Current state items + Object.keys(state.interactionState.groupsVisited).forEach( + (groupId) => (changedInteractionState.groupsVisited[groupId] = true) + ); + + //Add new Groups + groupIds.forEach( + (groupId) => (changedInteractionState.groupsVisited[groupId] = true) + ); + + return { + ...state, + interactionState: { + ...state.interactionState, + groupsVisited: changedInteractionState.groupsVisited, + }, + }; + } + } + return state; +} + +function setInitialCurrentGroup( + state: Configurator.Configuration +): Configurator.Configuration { + if (state.interactionState.currentGroup) { + return state; + } + let initialCurrentGroup = null; + + if (state?.flatGroups?.length > 0) { + initialCurrentGroup = state?.flatGroups[0]?.id; + } + + const result = { + ...state, + interactionState: { + ...state.interactionState, + currentGroup: initialCurrentGroup, + }, + }; + + return result; +} + +function takeOverChanges( + action: + | ConfiguratorActions.CreateConfigurationSuccess + | ConfiguratorActions.ReadConfigurationSuccess + | ConfiguratorActions.UpdatePriceSummarySuccess + | ConfiguratorActions.UpdateConfigurationFinalizeSuccess + | ConfiguratorActions.ReadCartEntryConfigurationSuccess + | ConfiguratorActions.ReadOrderEntryConfigurationSuccess, + state: Configurator.Configuration +): Configurator.Configuration { + const content = { ...action.payload }; + const result = { + ...state, + ...content, + interactionState: { + ...state.interactionState, + ...content.interactionState, + issueNavigationDone: true, + }, + }; + return result; +} diff --git a/feature-libs/product-configurator/rulebased/core/state/reducers/index.ts b/feature-libs/product-configurator/rulebased/core/state/reducers/index.ts new file mode 100644 index 00000000000..27688b6c547 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/reducers/index.ts @@ -0,0 +1,25 @@ +import { InjectionToken, Provider } from '@angular/core'; +import { ActionReducerMap } from '@ngrx/store'; +import { StateUtils } from '@spartacus/core'; +import { Configurator } from '../../model/configurator.model'; +import { ConfiguratorState, CONFIGURATOR_DATA } from '../configurator-state'; +import { configuratorReducer } from './configurator.reducer'; + +export function getConfiguratorReducers(): ActionReducerMap { + return { + configurations: StateUtils.entityProcessesLoaderReducer< + Configurator.Configuration + >(CONFIGURATOR_DATA, configuratorReducer), + }; +} + +export const configuratorReducerToken: InjectionToken> = new InjectionToken>( + 'ConfiguratorReducers' +); + +export const configuratorReducerProvider: Provider = { + provide: configuratorReducerToken, + useFactory: getConfiguratorReducers, +}; diff --git a/feature-libs/product-configurator/rulebased/core/state/rulebased-configurator-state.module.ts b/feature-libs/product-configurator/rulebased/core/state/rulebased-configurator-state.module.ts new file mode 100644 index 00000000000..275a9f622ca --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/rulebased-configurator-state.module.ts @@ -0,0 +1,23 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; +import { StateModule } from '@spartacus/core'; +import { CONFIGURATOR_FEATURE } from './configurator-state'; +import { ConfiguratorEffects } from './effects/index'; +import { + configuratorReducerProvider, + configuratorReducerToken, +} from './reducers/index'; + +@NgModule({ + imports: [ + CommonModule, + + StateModule, + StoreModule.forFeature(CONFIGURATOR_FEATURE, configuratorReducerToken), + EffectsModule.forFeature(ConfiguratorEffects), + ], + providers: [configuratorReducerProvider], +}) +export class RulebasedConfiguratorStateModule {} diff --git a/feature-libs/product-configurator/rulebased/core/state/selectors/configurator-group.selectors.ts b/feature-libs/product-configurator/rulebased/core/state/selectors/configurator-group.selectors.ts new file mode 100644 index 00000000000..451c4c07a2b --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/selectors/configurator-group.selectors.ts @@ -0,0 +1 @@ +export * from './configurator.selector'; diff --git a/feature-libs/product-configurator/rulebased/core/state/selectors/configurator.selector.spec.ts b/feature-libs/product-configurator/rulebased/core/state/selectors/configurator.selector.spec.ts new file mode 100644 index 00000000000..c183e189738 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/selectors/configurator.selector.spec.ts @@ -0,0 +1,199 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { select, Store, StoreModule } from '@ngrx/store'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { Configurator } from '../../model/configurator.model'; +import { ConfiguratorActions } from '../actions'; +import { + CONFIGURATOR_FEATURE, + StateWithConfigurator, +} from '../configurator-state'; +import * as fromReducers from '../reducers/index'; +import { ConfiguratorSelectors } from './index'; + +describe('Configurator selectors', () => { + let store: Store; + let configuratorUtils: CommonConfiguratorUtilsService; + const productCode = 'CONF_LAPTOP'; + let owner: CommonConfigurator.Owner = {}; + let configuration: Configurator.Configuration = { + configId: 'a', + }; + let configurationWithInteractionState: Configurator.Configuration = { + ...configuration, + interactionState: { + currentGroup: null, + groupsVisited: {}, + menuParentGroup: null, + issueNavigationDone: true, + }, + }; + const GROUP_ID = 'currentGroupId'; + const GROUP_ID2 = 'currentGroupId2'; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature( + CONFIGURATOR_FEATURE, + fromReducers.getConfiguratorReducers() + ), + ], + }); + + store = TestBed.inject(Store as Type>); + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + owner = { + type: CommonConfigurator.OwnerType.PRODUCT, + id: productCode, + }; + configuration = { + configId: 'a', + productCode: productCode, + owner: owner, + }; + configurationWithInteractionState = { + ...configurationWithInteractionState, + ...configuration, + }; + configuratorUtils.setOwnerKey(owner); + spyOn(store, 'dispatch').and.callThrough(); + }); + + it('should return empty content when selecting with content selector initially', () => { + let result: Configurator.Configuration; + store + .pipe( + select( + ConfiguratorSelectors.getConfigurationFactory(configuration.owner.key) + ) + ) + .subscribe((value) => (result = value)); + + expect(result).toEqual(undefined); + }); + + it('should return configuration content when selecting with content selector when action was successful', () => { + let result: Configurator.Configuration; + store.dispatch( + new ConfiguratorActions.CreateConfigurationSuccess(configuration) + ); + + store + .pipe( + select( + ConfiguratorSelectors.getConfigurationFactory(configuration.owner.key) + ) + ) + .subscribe((value) => (result = value)); + + expect(result).toEqual(configurationWithInteractionState); + }); + + it('should return pending changes as false for an initial call', () => { + store + .pipe( + select(ConfiguratorSelectors.hasPendingChanges(configuration.owner.key)) + ) + .subscribe((hasPendingChanges) => expect(hasPendingChanges).toBe(false)); + }); + + it('should return pending changes as true if an update has happenend', () => { + store.dispatch(new ConfiguratorActions.UpdateConfiguration(configuration)); + store + .pipe( + select(ConfiguratorSelectors.hasPendingChanges(configuration.owner.key)) + ) + .subscribe((hasPendingChanges) => expect(hasPendingChanges).toBe(true)); + }); + + it('should return current group content selector when action was successful', () => { + store.dispatch( + new ConfiguratorActions.SetCurrentGroup({ + entityKey: configuration.owner.key, + currentGroup: GROUP_ID, + }) + ); + store + .pipe( + select(ConfiguratorSelectors.getCurrentGroup(configuration.owner.key)) + ) + .subscribe((value) => expect(value).toEqual(GROUP_ID)); + }); + + it('should get visited status for group - initial', () => { + store + .pipe( + select( + ConfiguratorSelectors.isGroupVisited( + configuration.owner.key, + GROUP_ID + ) + ) + ) + .subscribe((value) => expect(value).toEqual(undefined)); + }); + + it('should get visited status for group', () => { + store.dispatch( + new ConfiguratorActions.SetGroupsVisited({ + entityKey: configuration.owner.key, + visitedGroups: [GROUP_ID], + }) + ); + store + .pipe( + select( + ConfiguratorSelectors.isGroupVisited( + configuration.owner.key, + GROUP_ID + ) + ) + ) + .subscribe((value) => expect(value).toEqual(true)); + }); + + it('should get visited status for group many groups, not all visited', () => { + store.dispatch( + new ConfiguratorActions.SetGroupsVisited({ + entityKey: configuration.owner.key, + visitedGroups: [GROUP_ID], + }) + ); + store + .pipe( + select( + ConfiguratorSelectors.areGroupsVisited(configuration.owner.key, [ + GROUP_ID2, + GROUP_ID, + ]) + ) + ) + .subscribe((value) => expect(value).toEqual(false)); + }); + + it('should get visited status for group many groups, all visited', () => { + store.dispatch( + new ConfiguratorActions.SetGroupsVisited({ + entityKey: configuration.owner.key, + visitedGroups: [GROUP_ID, GROUP_ID2], + }) + ); + store + .pipe( + select( + ConfiguratorSelectors.areGroupsVisited(configuration.owner.key, [ + GROUP_ID, + GROUP_ID2, + ]) + ) + ) + .subscribe((value) => expect(value).toEqual(true)); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/core/state/selectors/configurator.selector.ts b/feature-libs/product-configurator/rulebased/core/state/selectors/configurator.selector.ts new file mode 100644 index 00000000000..41459f483c3 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/selectors/configurator.selector.ts @@ -0,0 +1,96 @@ +import { + createFeatureSelector, + createSelector, + MemoizedSelector, +} from '@ngrx/store'; +import { StateUtils } from '@spartacus/core'; +import { Configurator } from '../../model/configurator.model'; +import { + ConfiguratorState, + CONFIGURATOR_FEATURE, + StateWithConfigurator, +} from '../configurator-state'; + +export const getConfigurationsState: MemoizedSelector< + StateWithConfigurator, + ConfiguratorState +> = createFeatureSelector(CONFIGURATOR_FEATURE); + +export const getConfigurationState: MemoizedSelector< + StateWithConfigurator, + StateUtils.EntityLoaderState +> = createSelector( + getConfigurationsState, + (state: ConfiguratorState) => state.configurations +); + +export const getConfigurationProcessLoaderStateFactory = ( + code: string +): MemoizedSelector< + StateWithConfigurator, + StateUtils.ProcessesLoaderState +> => { + return createSelector(getConfigurationState, (details) => + StateUtils.entityProcessesLoaderStateSelector(details, code) + ); +}; + +export const hasPendingChanges = ( + code: string +): MemoizedSelector => { + return createSelector(getConfigurationState, (details) => + StateUtils.entityHasPendingProcessesSelector(details, code) + ); +}; + +export const getConfigurationFactory = ( + code: string +): MemoizedSelector => { + return createSelector( + getConfigurationProcessLoaderStateFactory(code), + (configurationState) => StateUtils.loaderValueSelector(configurationState) + ); +}; + +export const getCurrentGroup = ( + ownerKey: string +): MemoizedSelector => { + return createSelector( + getConfigurationFactory(ownerKey), + (configuration) => + configuration?.interactionState?.currentGroup || undefined + ); +}; + +export const isGroupVisited = ( + ownerKey: string, + groupId: string +): MemoizedSelector => { + return createSelector( + getConfigurationFactory(ownerKey), + (configuration) => + configuration?.interactionState?.groupsVisited[groupId] || undefined + ); +}; + +export const areGroupsVisited = ( + ownerKey: string, + groupIds: string[] +): MemoizedSelector => { + return createSelector(getConfigurationFactory(ownerKey), (configuration) => { + let isVisited = true; + groupIds.forEach((groupId) => { + if (!isVisited) { + return; + } + + isVisited = + configuration?.interactionState?.groupsVisited[groupId] || undefined; + if (isVisited === undefined) { + isVisited = false; + } + }); + + return isVisited; + }); +}; diff --git a/feature-libs/product-configurator/rulebased/core/state/selectors/index.ts b/feature-libs/product-configurator/rulebased/core/state/selectors/index.ts new file mode 100644 index 00000000000..7d5c7f07f3f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/core/state/selectors/index.ts @@ -0,0 +1,2 @@ +import * as ConfiguratorSelectors from './configurator-group.selectors'; +export { ConfiguratorSelectors }; diff --git a/feature-libs/product-configurator/rulebased/index.ts b/feature-libs/product-configurator/rulebased/index.ts new file mode 100644 index 00000000000..c8787ad831f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/index.ts @@ -0,0 +1,5 @@ +export * from './components/index'; +export * from './core/index'; +export * from './occ/variant/index'; +export * from './rulebased-configurator.module'; +export * from './shared/testing/index'; diff --git a/feature-libs/product-configurator/rulebased/ng-package.json b/feature-libs/product-configurator/rulebased/ng-package.json new file mode 100644 index 00000000000..6425bef423d --- /dev/null +++ b/feature-libs/product-configurator/rulebased/ng-package.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts", + "umdModuleIds": { + "@spartacus/core": "core", + "@spartacus/storefront": "storefront", + "@ng-select/ng-select": "ngSelect", + "@ngrx/store": "store", + "@ngrx/effects": "effects" + } + } +} diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/index.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/index.ts new file mode 100644 index 00000000000..58171fce5d3 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/index.ts @@ -0,0 +1,6 @@ +export * from './occ-configurator-variant-add-to-cart-serializer'; +export * from './occ-configurator-variant-normalizer'; +export * from './occ-configurator-variant-overview-normalizer'; +export * from './occ-configurator-variant-price-summary-normalizer'; +export * from './occ-configurator-variant-serializer'; +export * from './occ-configurator-variant-update-cart-entry-serializer'; diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-add-to-cart-serializer.spec.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-add-to-cart-serializer.spec.ts new file mode 100644 index 00000000000..729e43a946f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-add-to-cart-serializer.spec.ts @@ -0,0 +1,55 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { OccConfigurator } from '../variant-configurator-occ.models'; +import { Configurator } from './../../../core/model/configurator.model'; +import { OccConfiguratorVariantAddToCartSerializer } from './occ-configurator-variant-add-to-cart-serializer'; + +describe('OccConfiguratorVariantAddToCartSerializer', () => { + let occConfiguratorVariantAddToCartSerializer: OccConfiguratorVariantAddToCartSerializer; + + const USER_ID = 'theUser'; + const CART_ID = '98876'; + const PRODUCT_CODE = 'CPQ_LAPTOP'; + const QUANTITY = 1; + const CONFIG_ID = '12314'; + + const sourceParameters: Configurator.AddToCartParameters = { + userId: USER_ID, + cartId: CART_ID, + productCode: PRODUCT_CODE, + quantity: QUANTITY, + configId: CONFIG_ID, + owner: {}, + }; + + const targetParameters: OccConfigurator.AddToCartParameters = { + userId: USER_ID, + cartId: CART_ID, + product: { code: PRODUCT_CODE }, + quantity: QUANTITY, + configId: CONFIG_ID, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [OccConfiguratorVariantAddToCartSerializer], + }); + + occConfiguratorVariantAddToCartSerializer = TestBed.inject( + OccConfiguratorVariantAddToCartSerializer as Type< + OccConfiguratorVariantAddToCartSerializer + > + ); + }); + + it('should convert addToCart parameters to occAddToCartParameters', () => { + const convertedParameters = occConfiguratorVariantAddToCartSerializer.convert( + sourceParameters + ); + expect(convertedParameters.userId).toEqual(targetParameters.userId); + expect(convertedParameters.configId).toEqual(targetParameters.configId); + expect(convertedParameters.product.code).toEqual( + targetParameters.product.code + ); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-add-to-cart-serializer.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-add-to-cart-serializer.ts new file mode 100644 index 00000000000..c0bd0b6c661 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-add-to-cart-serializer.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { OccConfigurator } from '../variant-configurator-occ.models'; +import { Configurator } from './../../../core/model/configurator.model'; + +@Injectable({ providedIn: 'root' }) +export class OccConfiguratorVariantAddToCartSerializer + implements + Converter< + Configurator.AddToCartParameters, + OccConfigurator.AddToCartParameters + > { + constructor() {} + + convert( + source: Configurator.AddToCartParameters, + target?: OccConfigurator.AddToCartParameters + ): OccConfigurator.AddToCartParameters { + const resultTarget: OccConfigurator.AddToCartParameters = { + ...target, + userId: source.userId, + cartId: source.cartId, + product: { code: source.productCode }, + quantity: source.quantity, + configId: source.configId, + }; + + return resultTarget; + } +} diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-normalizer.spec.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-normalizer.spec.ts new file mode 100644 index 00000000000..7e25cd8eb24 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-normalizer.spec.ts @@ -0,0 +1,702 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + ConverterService, + OccConfig, + TranslationService, +} from '@spartacus/core'; +import { Observable, of } from 'rxjs'; +import { OccConfigurator } from '../variant-configurator-occ.models'; +import { Configurator } from './../../../core/model/configurator.model'; +import { OccConfiguratorVariantNormalizer } from './occ-configurator-variant-normalizer'; + +const attributeName = 'name'; +const valueKey = 'BK'; +const valueName = 'Black'; +const valueKey2 = 'BE'; +const selectedFlag = true; +const requiredFlag = true; +const generalGroupName = '_GEN'; +const generalGroupDescription = 'General'; +const groupKey = generalGroupName; +const conflictHeaderGroupName = Configurator.GroupType.CONFLICT_HEADER_GROUP; +const conflictHeaderGroupDescription = 'Resolve issues for options...'; +const conflictGroupName = 'Color'; +const conflictGroupPrefix = 'Conflict for '; +const conflictExplanation = + 'The selected value is conflicting withour selections.'; + +const groupName = 'GROUP1'; +const groupDescription = 'The Group Name'; +let flatGroups: Configurator.Group[] = []; +let groups: Configurator.Group[] = []; + +const occImage: OccConfigurator.Image = { + altText: 'Alternate Text for Image', + format: OccConfigurator.ImageFormatType.VALUE_IMAGE, + imageType: OccConfigurator.ImageType.PRIMARY, + url: 'media?This%20%is%20%a%20%URL', +}; + +const occAttribute: OccConfigurator.Attribute = { + name: attributeName, + images: [occImage], + key: groupKey, +}; +const occAttributeWithValues: OccConfigurator.Attribute = { + name: attributeName, + required: requiredFlag, + type: OccConfigurator.UiType.RADIO_BUTTON, + key: groupKey, + domainValues: [ + { key: valueKey, images: [occImage] }, + { key: valueKey2, selected: selectedFlag }, + ], +}; +const attributeRBWithValues: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.RADIOBUTTON, + selectedSingleValue: 'SomeValue', +}; +const attributeRBWoValues: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.RADIOBUTTON, + selectedSingleValue: '', +}; +const attributeDDWithValues: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.DROPDOWN, + selectedSingleValue: 'SomeValue', +}; +const attributeDDWoValues: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.DROPDOWN, + selectedSingleValue: '', +}; +const attributeSSIWithValues: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.SINGLE_SELECTION_IMAGE, + selectedSingleValue: 'SomeValue', +}; +const attributeSSIWoValues: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.SINGLE_SELECTION_IMAGE, + selectedSingleValue: '', +}; +const attributeStringWoValue: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.STRING, +}; +const attributeStringWithValue: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.STRING, + userInput: 'SomeValue', +}; +const attributeNumericWoValue: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.NUMERIC, +}; +const attributeNumericWithValue: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.NUMERIC, + userInput: '123', +}; +const attributeCheckboxWOValue: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.CHECKBOXLIST, + values: [ + { + name: 'name1', + selected: false, + }, + { + name: 'name2', + selected: false, + }, + ], +}; +const attributeCheckboxWithValue: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.CHECKBOXLIST, + values: [ + { + name: 'name1', + selected: true, + }, + { + name: 'name2', + selected: false, + }, + ], +}; +const attributeMSIWOValue: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.MULTI_SELECTION_IMAGE, + values: [ + { + name: 'name1', + selected: false, + }, + { + name: 'name2', + selected: false, + }, + ], +}; +const attributeMSIWithValue: Configurator.Attribute = { + name: attributeName, + required: requiredFlag, + uiType: Configurator.UiType.MULTI_SELECTION_IMAGE, + values: [ + { + name: 'name1', + selected: true, + }, + { + name: 'name2', + selected: false, + }, + ], +}; +const configuration: OccConfigurator.Configuration = { + complete: true, + rootProduct: 'CONF_PRODUCT', + groups: [ + { + attributes: [occAttributeWithValues], + subGroups: [{ attributes: [occAttributeWithValues] }], + }, + { + attributes: [occAttributeWithValues], + }, + ], +}; + +const group: OccConfigurator.Group = { + name: groupName, + description: groupDescription, + groupType: OccConfigurator.GroupType.CSTIC_GROUP, + attributes: [occAttributeWithValues], +}; + +const occConflictGroup: OccConfigurator.Group = { + name: conflictGroupName, + description: conflictExplanation, + groupType: OccConfigurator.GroupType.CONFLICT, + attributes: [occAttributeWithValues], +}; + +const occValue: OccConfigurator.Value = { + key: valueKey, + langDepName: valueName, +}; + +class MockConverterService { + convert() {} +} + +class MockTranslationService { + translate(key: string, options: any = {}): Observable { + switch (key) { + case 'configurator.group.general': + return of(generalGroupDescription); + case 'configurator.group.conflictHeader': + return of(conflictHeaderGroupDescription); + case 'configurator.group.conflictGroup': + return of(conflictGroupPrefix + options.attribute); + default: + return of(key); + } + } +} + +const MockOccModuleConfig: OccConfig = { + backend: { + occ: { + baseUrl: 'https://occBackendBaseUrl/', + prefix: '', + }, + media: { + baseUrl: 'https://mediaBackendBaseUrl/', + }, + }, +}; + +describe('OccConfiguratorVariantNormalizer', () => { + let occConfiguratorVariantNormalizer: OccConfiguratorVariantNormalizer; + let occConfig: OccConfig; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OccConfiguratorVariantNormalizer, + { provide: ConverterService, useClass: MockConverterService }, + { provide: OccConfig, useValue: MockOccModuleConfig }, + { provide: TranslationService, useClass: MockTranslationService }, + ], + }); + + occConfiguratorVariantNormalizer = TestBed.inject( + OccConfiguratorVariantNormalizer as Type + ); + occConfig = TestBed.inject(OccConfig as Type); + groups = []; + flatGroups = []; + }); + + it('should be created', () => { + expect(occConfiguratorVariantNormalizer).toBeTruthy(); + }); + + it('should convert a configuration', () => { + const result = occConfiguratorVariantNormalizer.convert(configuration); + expect(result.complete).toBe(true); + }); + + it('should not touch isRequiredCartUpdate and isCartEntryUpdatePending when converting a configuration', () => { + const result: Configurator.Configuration = occConfiguratorVariantNormalizer.convert( + configuration + ); + expect(result.isCartEntryUpdateRequired).toBeUndefined(); + }); + + it('should convert subgroups', () => { + const result = occConfiguratorVariantNormalizer.convert(configuration); + expect(result.groups[0].subGroups[0].attributes.length).toBe(1); + }); + + it('should convert empty subgroups to empty array', () => { + const result = occConfiguratorVariantNormalizer.convert(configuration); + expect(result.groups[1].subGroups.length).toBe(0); + }); + + it('should convert attributes and values', () => { + const result = occConfiguratorVariantNormalizer.convert(configuration); + const attributes = result.groups[0].attributes; + expect(attributes).toBeDefined(); + expect(attributes.length).toBe(1); + const attribute = attributes[0]; + expect(attribute.name).toBe(attributeName); + expect(attribute.required).toBe(requiredFlag); + expect(attribute.selectedSingleValue).toBe(valueKey2); + expect(attribute.uiType).toBe(Configurator.UiType.RADIOBUTTON); + const values = attribute.values; + expect(values.length).toBe(2); + }); + + it('should convert values', () => { + const values: Configurator.Value[] = []; + occConfiguratorVariantNormalizer.convertValue(occValue, values); + expect(values.length).toBe(1); + expect(values[0].valueCode).toBe(valueKey); + }); + + it('should convert attributes and do not complain if no domain values are present', () => { + const attributes: Configurator.Attribute[] = []; + occConfiguratorVariantNormalizer.convertAttribute(occAttribute, attributes); + expect(attributes.length).toBe(1); + expect(attributes[0].name).toBe(attributeName); + }); + + it('should tell if attribute is numeric and know if negative values are allowed', () => { + const attributes: Configurator.Attribute[] = []; + const numericOccAttribute: OccConfigurator.Attribute = { + value: '23.234', + negativeAllowed: true, + type: OccConfigurator.UiType.READ_ONLY, + key: groupKey, + }; + occConfiguratorVariantNormalizer.convertAttribute( + numericOccAttribute, + attributes + ); + + expect(attributes[0].negativeAllowed).toBe(true); + }); + + it('should increase maximum length if negative numbers are allowed', () => { + const attributes: Configurator.Attribute[] = []; + const numericOccAttribute: OccConfigurator.Attribute = { + maxlength: 3, + negativeAllowed: true, + key: groupKey, + }; + occConfiguratorVariantNormalizer.convertAttribute( + numericOccAttribute, + attributes + ); + + expect(attributes[0].maxlength).toBe(numericOccAttribute.maxlength + 1); + }); + + it('should convert a standard group', () => { + occConfiguratorVariantNormalizer.convertGroup(group, groups, flatGroups); + expect(groups[0].description).toBe(groupDescription); + }); + + it('should convert a standard group and conflict group but not conflict-header group and sub-item-group', () => { + occConfiguratorVariantNormalizer.convertGroup(group, groups, flatGroups); + expect(flatGroups.length).toBe(1); + occConfiguratorVariantNormalizer.convertGroup( + occConflictGroup, + groups, + flatGroups + ); + expect(flatGroups.length).toBe(2); + group.groupType = OccConfigurator.GroupType.INSTANCE; + occConfiguratorVariantNormalizer.convertGroup(group, groups, flatGroups); + expect(flatGroups.length).toBe(2); + occConflictGroup.groupType = OccConfigurator.GroupType.CONFLICT_HEADER; + occConfiguratorVariantNormalizer.convertGroup( + occConflictGroup, + groups, + flatGroups + ); + expect(flatGroups.length).toBe(2); + }); + + it('should convert a group with no attributes', () => { + const groupsWithoutAttributes: OccConfigurator.Group = { + name: groupName, + }; + + occConfiguratorVariantNormalizer.convertGroup( + groupsWithoutAttributes, + groups, + flatGroups + ); + expect(groups[0].name).toBe(groupName); + }); + + it('should convert a general group', () => { + const generalGroup: OccConfigurator.Group = { + name: generalGroupName, + }; + + occConfiguratorVariantNormalizer.convertGroup( + generalGroup, + groups, + flatGroups + ); + expect(groups[0].description).toBe(generalGroupDescription); + }); + + it('should set description for a general group', () => { + const generalGroup: Configurator.Group = { + name: generalGroupName, + }; + + occConfiguratorVariantNormalizer.setGroupDescription(generalGroup); + expect(generalGroup.description).toBe(generalGroupDescription); + }); + + it('should set description for conflict header group', () => { + const conflictHeaderGroup: Configurator.Group = { + groupType: Configurator.GroupType.CONFLICT_HEADER_GROUP, + name: conflictHeaderGroupName, + }; + + occConfiguratorVariantNormalizer.setGroupDescription(conflictHeaderGroup); + expect(conflictHeaderGroup.description).toBe( + conflictHeaderGroupDescription + ); + }); + + it('should set description for conflict group and should store conflict explanation in group.name', () => { + const conflictGroup: Configurator.Group = { + groupType: Configurator.GroupType.CONFLICT_GROUP, + name: conflictGroupName, + description: conflictExplanation, + }; + + occConfiguratorVariantNormalizer.setGroupDescription(conflictGroup); + expect(conflictGroup.description).toBe( + conflictGroupPrefix + conflictGroupName + ); + expect(conflictGroup.name).toBe(conflictExplanation); + }); + + it('should set selectedSingleValue', () => { + const configAttribute: Configurator.Attribute = { + name: attributeName, + values: [ + { valueCode: valueKey }, + { valueCode: valueKey2, selected: selectedFlag }, + ], + }; + occConfiguratorVariantNormalizer.setSelectedSingleValue(configAttribute); + expect(configAttribute.selectedSingleValue).toBe(valueKey2); + }); + + it('should not set selectedSingleValue for multi-valued attributes', () => { + const configAttribute: Configurator.Attribute = { + name: attributeName, + values: [ + { valueCode: valueKey, selected: selectedFlag }, + { valueCode: valueKey2, selected: selectedFlag }, + ], + }; + occConfiguratorVariantNormalizer.setSelectedSingleValue(configAttribute); + expect(configAttribute.selectedSingleValue).toBeUndefined(); + }); + + it('should return UIType Radio Button for Radio Button occ configurator type', () => { + expect( + occConfiguratorVariantNormalizer.convertAttributeType( + OccConfigurator.UiType.RADIO_BUTTON + ) + ).toBe(Configurator.UiType.RADIOBUTTON); + }); + + it('should convert numeric attribute type correctly', () => { + expect( + occConfiguratorVariantNormalizer.convertAttributeType( + OccConfigurator.UiType.NUMERIC + ) + ).toBe(Configurator.UiType.NUMERIC); + }); + + it('should convert read-only attribute type correctly', () => { + expect( + occConfiguratorVariantNormalizer.convertAttributeType( + OccConfigurator.UiType.READ_ONLY + ) + ).toBe(Configurator.UiType.READ_ONLY); + }); + + it('should return UIType Drop Down for Drop Down occ configurator type', () => { + expect( + occConfiguratorVariantNormalizer.convertAttributeType( + OccConfigurator.UiType.DROPDOWN + ) + ).toBe(Configurator.UiType.DROPDOWN); + }); + + it('should return UIType Checkbox for Checkbox occ configurator type', () => { + expect( + occConfiguratorVariantNormalizer.convertAttributeType( + OccConfigurator.UiType.CHECK_BOX_LIST + ) + ).toBe(Configurator.UiType.CHECKBOXLIST); + }); + + it('should return UIType Checkbox for Checkbox occ configurator type', () => { + expect( + occConfiguratorVariantNormalizer.convertAttributeType( + OccConfigurator.UiType.SINGLE_SELECTION_IMAGE + ) + ).toBe(Configurator.UiType.SINGLE_SELECTION_IMAGE); + }); + + it('should return UIType String for String occ configurator type', () => { + expect( + occConfiguratorVariantNormalizer.convertAttributeType( + OccConfigurator.UiType.STRING + ) + ).toBe(Configurator.UiType.STRING); + }); + + it('should return UIType checkox for checkbox occ configurator type', () => { + expect( + occConfiguratorVariantNormalizer.convertAttributeType( + OccConfigurator.UiType.CHECK_BOX + ) + ).toBe(Configurator.UiType.CHECKBOX); + }); + + it('should return UIType multi selection image for corresponding occ configurator type', () => { + expect( + occConfiguratorVariantNormalizer.convertAttributeType( + OccConfigurator.UiType.MULTI_SELECTION_IMAGE + ) + ).toBe(Configurator.UiType.MULTI_SELECTION_IMAGE); + }); + + it('should return UIType Not Implemented for unkonwn occ configurator type', () => { + expect( + occConfiguratorVariantNormalizer.convertAttributeType( + OccConfigurator.UiType.DROPDOWN_ADDITIONAL_INPUT + ) + ).toBe(Configurator.UiType.NOT_IMPLEMENTED); + }); + + it('should convert group types properly', () => { + expect( + occConfiguratorVariantNormalizer.convertGroupType( + OccConfigurator.GroupType.CSTIC_GROUP + ) + ).toBe(Configurator.GroupType.ATTRIBUTE_GROUP); + + expect( + occConfiguratorVariantNormalizer.convertGroupType( + OccConfigurator.GroupType.CONFLICT_HEADER + ) + ).toBe(Configurator.GroupType.CONFLICT_HEADER_GROUP); + + expect( + occConfiguratorVariantNormalizer.convertGroupType( + OccConfigurator.GroupType.CONFLICT + ) + ).toBe(Configurator.GroupType.CONFLICT_GROUP); + + expect( + occConfiguratorVariantNormalizer.convertGroupType( + OccConfigurator.GroupType.INSTANCE + ) + ).toBe(Configurator.GroupType.SUB_ITEM_GROUP); + }); + + it('should convert image types properly', () => { + expect( + occConfiguratorVariantNormalizer.convertImageType( + OccConfigurator.ImageType.GALLERY + ) + ).toBe(Configurator.ImageType.GALLERY); + + expect( + occConfiguratorVariantNormalizer.convertImageType( + OccConfigurator.ImageType.PRIMARY + ) + ).toBe(Configurator.ImageType.PRIMARY); + }); + + it('should convert image format types properly', () => { + expect( + occConfiguratorVariantNormalizer.convertImageFormatType( + OccConfigurator.ImageFormatType.VALUE_IMAGE + ) + ).toBe(Configurator.ImageFormatType.VALUE_IMAGE); + + expect( + occConfiguratorVariantNormalizer.convertImageFormatType( + OccConfigurator.ImageFormatType.CSTIC_IMAGE + ) + ).toBe(Configurator.ImageFormatType.ATTRIBUTE_IMAGE); + }); + + it('should convert image with media URL configured', () => { + const images = []; + occConfig.backend.media.baseUrl = 'https://mediaBackendBaseUrl/'; + + occConfiguratorVariantNormalizer.convertImage(occImage, images); + + expect(images.length).toBe(1); + expect(images[0].url).toBe( + 'https://mediaBackendBaseUrl/media?This%20%is%20%a%20%URL' + ); + + occConfiguratorVariantNormalizer.convertImage(occImage, images); + expect(images.length).toBe(2); + }); + + it('should convert image with no media URL configured', () => { + const images = []; + occConfig.backend.media.baseUrl = null; + + occConfiguratorVariantNormalizer.convertImage(occImage, images); + + expect(images.length).toBe(1); + expect(images[0].url).toBe( + 'https://occBackendBaseUrl/media?This%20%is%20%a%20%URL' + ); + }); + + describe('check the setting of incomplete', () => { + it('should set incomplete by string type correctly', () => { + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeStringWoValue + ); + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeStringWithValue + ); + + expect(attributeStringWoValue.incomplete).toBe(true); + expect(attributeStringWithValue.incomplete).toBe(false); + }); + + it('should set incomplete by numeric type correctly', () => { + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeNumericWoValue + ); + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeNumericWithValue + ); + + expect(attributeNumericWoValue.incomplete).toBe(true); + expect(attributeNumericWithValue.incomplete).toBe(false); + }); + + it('should set incomplete by radio button type correctly', () => { + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeRBWoValues + ); + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeRBWithValues + ); + + expect(attributeRBWoValues.incomplete).toBe(true); + expect(attributeRBWithValues.incomplete).toBe(false); + }); + + it('should set incomplete by drop-down type correctly', () => { + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeDDWoValues + ); + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeDDWithValues + ); + + expect(attributeDDWoValues.incomplete).toBe(true); + expect(attributeDDWithValues.incomplete).toBe(false); + }); + + it('should set incomplete by single-selection-image type correctly', () => { + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeSSIWoValues + ); + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeSSIWithValues + ); + + expect(attributeSSIWoValues.incomplete).toBe(true); + expect(attributeSSIWithValues.incomplete).toBe(false); + }); + + it('should set incomplete by checkbox type correctly', () => { + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeCheckboxWOValue + ); + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeCheckboxWithValue + ); + + expect(attributeCheckboxWOValue.incomplete).toBe(true); + expect(attributeCheckboxWithValue.incomplete).toBe(false); + }); + + it('should set incomplete by multi-selection-image type correctly', () => { + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeMSIWOValue + ); + occConfiguratorVariantNormalizer.compileAttributeIncomplete( + attributeMSIWithValue + ); + + expect(attributeMSIWOValue.incomplete).toBe(true); + expect(attributeMSIWithValue.incomplete).toBe(false); + }); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-normalizer.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-normalizer.ts new file mode 100644 index 00000000000..87b3223b58c --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-normalizer.ts @@ -0,0 +1,328 @@ +import { Injectable } from '@angular/core'; +import { Converter, OccConfig, TranslationService } from '@spartacus/core'; +import { take } from 'rxjs/operators'; +import { OccConfigurator } from '../variant-configurator-occ.models'; +import { Configurator } from './../../../core/model/configurator.model'; + +@Injectable({ providedIn: 'root' }) +export class OccConfiguratorVariantNormalizer + implements + Converter { + constructor( + protected config: OccConfig, + protected translation: TranslationService + ) {} + + convert( + source: OccConfigurator.Configuration, + target?: Configurator.Configuration + ): Configurator.Configuration { + const resultTarget: Configurator.Configuration = { + ...target, + configId: source.configId, + complete: source.complete, + totalNumberOfIssues: source.totalNumberOfIssues, + productCode: source.rootProduct, + groups: [], + flatGroups: [], + }; + source.groups.forEach((group) => + this.convertGroup(group, resultTarget.groups, resultTarget.flatGroups) + ); + return resultTarget; + } + + convertGroup( + source: OccConfigurator.Group, + groupList: Configurator.Group[], + flatGroupList: Configurator.Group[] + ) { + const attributes: Configurator.Attribute[] = []; + if (source.attributes) { + source.attributes.forEach((sourceAttribute) => + this.convertAttribute(sourceAttribute, attributes) + ); + } + + const group = { + description: source.description, + configurable: source.configurable, + complete: source.complete, + consistent: source.consistent, + groupType: this.convertGroupType(source.groupType), + name: source.name, + id: source.id, + attributes: attributes, + subGroups: [], + }; + + this.setGroupDescription(group); + + if (source.subGroups) { + source.subGroups.forEach((sourceSubGroup) => + this.convertGroup(sourceSubGroup, group.subGroups, flatGroupList) + ); + } + + if ( + group.groupType === Configurator.GroupType.ATTRIBUTE_GROUP || + group.groupType === Configurator.GroupType.CONFLICT_GROUP + ) { + flatGroupList.push(group); + } + + groupList.push(group); + } + + getGroupId(key: string, name: string): string { + return key.replace('@' + name, ''); + } + + convertAttribute( + sourceAttribute: OccConfigurator.Attribute, + attributeList: Configurator.Attribute[] + ): void { + const attribute: Configurator.Attribute = { + name: sourceAttribute.name, + label: sourceAttribute.langDepName, + required: sourceAttribute.required, + uiType: this.convertAttributeType(sourceAttribute.type), + values: [], + groupId: this.getGroupId(sourceAttribute.key, sourceAttribute.name), + userInput: sourceAttribute.formattedValue, + maxlength: + sourceAttribute.maxlength + (sourceAttribute.negativeAllowed ? 1 : 0), + numDecimalPlaces: sourceAttribute.numberScale, + negativeAllowed: sourceAttribute.negativeAllowed, + numTotalLength: sourceAttribute.typeLength, + selectedSingleValue: null, + images: [], + hasConflicts: sourceAttribute?.conflicts?.length > 0 ? true : false, + }; + + if (sourceAttribute.images) { + sourceAttribute.images.forEach((occImage) => + this.convertImage(occImage, attribute.images) + ); + } + + if (sourceAttribute.domainValues) { + sourceAttribute.domainValues.forEach((value) => + this.convertValue(value, attribute.values) + ); + this.setSelectedSingleValue(attribute); + } + + //Has to be called after setSelectedSingleValue because it depends on the value of this property + this.compileAttributeIncomplete(attribute); + attributeList.push(attribute); + } + + setSelectedSingleValue(attribute: Configurator.Attribute) { + const selectedValues = attribute.values + .map((entry) => entry) + .filter((entry) => entry.selected); + if (selectedValues && selectedValues.length === 1) { + attribute.selectedSingleValue = selectedValues[0].valueCode; + } + } + + convertValue( + occValue: OccConfigurator.Value, + values: Configurator.Value[] + ): void { + const value: Configurator.Value = { + valueCode: occValue.key, + valueDisplay: occValue.langDepName, + name: occValue.name, + selected: occValue.selected, + images: [], + }; + + if (occValue.images) { + occValue.images.forEach((occImage) => + this.convertImage(occImage, value.images) + ); + } + + values.push(value); + } + + convertImage( + occImage: OccConfigurator.Image, + images: Configurator.Image[] + ): void { + const image: Configurator.Image = { + /** + * Traditionally, in an on-prem world, medias and other backend related calls + * are hosted at the same platform, but in a cloud setup, applications are + * typically distributed cross different environments. For media, we use the + * `backend.media.baseUrl` by default, but fallback to `backend.occ.baseUrl` + * if none provided. + */ + url: + (this.config.backend.media.baseUrl || + this.config.backend.occ.baseUrl || + '') + occImage.url, + altText: occImage.altText, + galleryIndex: occImage.galleryIndex, + type: this.convertImageType(occImage.imageType), + format: this.convertImageFormatType(occImage.format), + }; + + images.push(image); + } + + convertAttributeType(type: OccConfigurator.UiType): Configurator.UiType { + let uiType: Configurator.UiType; + switch (type) { + case OccConfigurator.UiType.RADIO_BUTTON: { + uiType = Configurator.UiType.RADIOBUTTON; + break; + } + case OccConfigurator.UiType.DROPDOWN: { + uiType = Configurator.UiType.DROPDOWN; + break; + } + case OccConfigurator.UiType.STRING: { + uiType = Configurator.UiType.STRING; + break; + } + case OccConfigurator.UiType.NUMERIC: { + uiType = Configurator.UiType.NUMERIC; + break; + } + case OccConfigurator.UiType.READ_ONLY: { + uiType = Configurator.UiType.READ_ONLY; + break; + } + case OccConfigurator.UiType.CHECK_BOX_LIST: { + uiType = Configurator.UiType.CHECKBOXLIST; + break; + } + case OccConfigurator.UiType.CHECK_BOX: { + uiType = Configurator.UiType.CHECKBOX; + break; + } + case OccConfigurator.UiType.MULTI_SELECTION_IMAGE: { + uiType = Configurator.UiType.MULTI_SELECTION_IMAGE; + break; + } + case OccConfigurator.UiType.SINGLE_SELECTION_IMAGE: { + uiType = Configurator.UiType.SINGLE_SELECTION_IMAGE; + break; + } + default: { + uiType = Configurator.UiType.NOT_IMPLEMENTED; + } + } + return uiType; + } + + convertGroupType( + groupType: OccConfigurator.GroupType + ): Configurator.GroupType { + switch (groupType) { + case OccConfigurator.GroupType.CSTIC_GROUP: + return Configurator.GroupType.ATTRIBUTE_GROUP; + case OccConfigurator.GroupType.INSTANCE: + return Configurator.GroupType.SUB_ITEM_GROUP; + case OccConfigurator.GroupType.CONFLICT_HEADER: + return Configurator.GroupType.CONFLICT_HEADER_GROUP; + case OccConfigurator.GroupType.CONFLICT: + return Configurator.GroupType.CONFLICT_GROUP; + } + } + + setGroupDescription(group: Configurator.Group): void { + switch (group.groupType) { + case Configurator.GroupType.CONFLICT_HEADER_GROUP: + this.translation + .translate('configurator.group.conflictHeader') + .pipe(take(1)) + .subscribe( + (conflictHeaderText) => (group.description = conflictHeaderText) + ); + break; + case Configurator.GroupType.CONFLICT_GROUP: + const conflictDescription = group.description; + this.translation + .translate('configurator.group.conflictGroup', { + attribute: group.name, + }) + .pipe(take(1)) + .subscribe( + (conflictGroupText) => (group.description = conflictGroupText) + ); + group.name = conflictDescription; + break; + default: + if (group.name !== '_GEN') { + return; + } + this.translation + .translate('configurator.group.general') + .pipe(take(1)) + .subscribe((generalText) => (group.description = generalText)); + } + } + + convertImageType( + imageType: OccConfigurator.ImageType + ): Configurator.ImageType { + switch (imageType) { + case OccConfigurator.ImageType.GALLERY: + return Configurator.ImageType.GALLERY; + case OccConfigurator.ImageType.PRIMARY: + return Configurator.ImageType.PRIMARY; + } + } + + convertImageFormatType( + formatType: OccConfigurator.ImageFormatType + ): Configurator.ImageFormatType { + switch (formatType) { + case OccConfigurator.ImageFormatType.VALUE_IMAGE: + return Configurator.ImageFormatType.VALUE_IMAGE; + case OccConfigurator.ImageFormatType.CSTIC_IMAGE: + return Configurator.ImageFormatType.ATTRIBUTE_IMAGE; + } + } + + compileAttributeIncomplete(attribute: Configurator.Attribute) { + //Default value for incomplete is false + attribute.incomplete = false; + + switch (attribute.uiType) { + case Configurator.UiType.RADIOBUTTON: + case Configurator.UiType.DROPDOWN: + case Configurator.UiType.SINGLE_SELECTION_IMAGE: { + if (!attribute.selectedSingleValue) { + attribute.incomplete = true; + } + break; + } + case Configurator.UiType.NUMERIC: + case Configurator.UiType.STRING: { + if (!attribute.userInput) { + attribute.incomplete = true; + } + break; + } + + case Configurator.UiType.CHECKBOXLIST: + case Configurator.UiType.CHECKBOX: + case Configurator.UiType.MULTI_SELECTION_IMAGE: { + const isOneValueSelected = + attribute.values.find((value) => value.selected) !== undefined + ? true + : false; + + if (!isOneValueSelected) { + attribute.incomplete = true; + } + break; + } + } + } +} diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-overview-normalizer.spec.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-overview-normalizer.spec.ts new file mode 100644 index 00000000000..c37b92cb651 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-overview-normalizer.spec.ts @@ -0,0 +1,213 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ConverterService, TranslationService } from '@spartacus/core'; +import { Observable, of } from 'rxjs'; +import { OccConfigurator } from '../variant-configurator-occ.models'; +import { Configurator } from './../../../core/model/configurator.model'; +import { OccConfiguratorVariantOverviewNormalizer } from './occ-configurator-variant-overview-normalizer'; + +const generalGroupName = '_GEN'; +const generalGroupDescription = 'General'; +const groupDescription = 'The Group Name'; +const configId = '1234-4568'; +const PRODUCT_CODE = 'PRODUCT'; +const totalNumberOfIssues = 2; + +class MockTranslationService { + translate(): Observable { + return of(generalGroupDescription); + } +} + +const convertedOverview: Configurator.Overview = { + configId: configId, + totalNumberOfIssues: totalNumberOfIssues, + priceSummary: {}, + productCode: PRODUCT_CODE, + groups: [ + { + id: '1', + groupDescription: groupDescription, + + attributes: [ + { + attribute: 'C1', + value: 'V1', + }, + ], + }, + { + id: '11', + groupDescription: undefined, + attributes: [], + }, + { + id: '2', + groupDescription: 'Group 2', + attributes: [ + { + attribute: 'C2', + value: 'V2', + }, + { + attribute: 'C3', + value: 'V3', + }, + ], + }, + ], +}; + +const group3: OccConfigurator.GroupOverview = { + id: '3', + groupDescription: 'SubGroup', + characteristicValues: [{ characteristic: 'C3', value: 'V3' }], + subGroups: [ + { + id: '4', + groupDescription: 'SubGroupLevel2', + characteristicValues: null, + }, + ], +}; +Object.freeze(group3); +const subGroups: OccConfigurator.GroupOverview[] = [group3]; +Object.freeze(subGroups); + +const group1: OccConfigurator.GroupOverview = { + id: '1', + groupDescription: groupDescription, + subGroups: [{ id: '11' }], + characteristicValues: [ + { + characteristic: 'C1', + value: 'V1', + }, + ], +}; +Object.freeze(group1); + +const generalGroup: OccConfigurator.GroupOverview = { + id: generalGroupName, + groupDescription: '', + characteristicValues: [ + { + characteristic: 'C1', + value: 'V1', + }, + ], +}; +Object.freeze(generalGroup); + +const overview: OccConfigurator.Overview = { + id: configId, + totalNumberOfIssues: totalNumberOfIssues, + pricing: {}, + productCode: PRODUCT_CODE, + groups: [ + group1, + { + id: '2', + groupDescription: 'Group 2', + characteristicValues: [ + { + characteristic: 'C2', + value: 'V2', + }, + { + characteristic: 'C3', + value: 'V3', + }, + ], + }, + ], +}; +Object.freeze(overview); + +class MockConverterService { + convert(source: OccConfigurator.Prices) { + return source.priceSummary; + } +} + +describe('OccConfiguratorVariantNormalizer', () => { + let occConfiguratorVariantOverviewNormalizer: OccConfiguratorVariantOverviewNormalizer; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OccConfiguratorVariantOverviewNormalizer, + { provide: ConverterService, useClass: MockConverterService }, + { provide: TranslationService, useClass: MockTranslationService }, + ], + }); + + occConfiguratorVariantOverviewNormalizer = TestBed.inject( + OccConfiguratorVariantOverviewNormalizer as Type< + OccConfiguratorVariantOverviewNormalizer + > + ); + }); + + it('should be created', () => { + expect(occConfiguratorVariantOverviewNormalizer).toBeTruthy(); + }); + + it('should convert the overview', () => { + const result = occConfiguratorVariantOverviewNormalizer.convert(overview); + expect(result).toEqual(convertedOverview); + }); + + it('should cover sub groups', () => { + const result = occConfiguratorVariantOverviewNormalizer.convert(overview); + expect(result.groups.length).toBe(3); + }); + + it('should be able to handle groups without attributes', () => { + const group: OccConfigurator.GroupOverview = { + subGroups: null, + characteristicValues: null, + id: group1.id, + }; + + const result = occConfiguratorVariantOverviewNormalizer.convertGroup(group); + expect(result.length).toBe(1); + expect(result[0].id).toBe(group.id); + }); + + it('should be able to handle groups with subgroups', () => { + const groupWithSubgroups: OccConfigurator.GroupOverview = { + subGroups: [group1, group3], + }; + + const result = occConfiguratorVariantOverviewNormalizer.convertGroup( + groupWithSubgroups + ); + expect(result.length).toBe(5); + }); + it('should set description for a general group', () => { + const generalTargetGroup: Configurator.GroupOverview = { + id: generalGroupName, + groupDescription: '', + attributes: [], + }; + + occConfiguratorVariantOverviewNormalizer.setGeneralDescription( + generalTargetGroup + ); + expect(generalTargetGroup.groupDescription).toBe(generalGroupDescription); + }); + + it('should convert a standard group', () => { + const result = occConfiguratorVariantOverviewNormalizer.convertGroup( + group1 + ); + expect(result[0].groupDescription).toBe(groupDescription); + }); + it('should convert a general group', () => { + const result = occConfiguratorVariantOverviewNormalizer.convertGroup( + generalGroup + ); + expect(result[0].groupDescription).toBe(generalGroupDescription); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-overview-normalizer.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-overview-normalizer.ts new file mode 100644 index 00000000000..86065717dab --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-overview-normalizer.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@angular/core'; +import { + Converter, + ConverterService, + TranslationService, +} from '@spartacus/core'; +import { take } from 'rxjs/operators'; +import { OccConfigurator } from '../variant-configurator-occ.models'; +import { Configurator } from './../../../core/model/configurator.model'; +import { VARIANT_CONFIGURATOR_PRICE_SUMMARY_NORMALIZER } from './../variant-configurator-occ.converters'; + +@Injectable({ providedIn: 'root' }) +export class OccConfiguratorVariantOverviewNormalizer + implements Converter { + constructor( + protected translation: TranslationService, + protected converterService: ConverterService + ) {} + + convert( + source: OccConfigurator.Overview, + target?: Configurator.Overview + ): Configurator.Overview { + const prices: OccConfigurator.Prices = { priceSummary: source.pricing }; + const resultTarget: Configurator.Overview = { + ...target, + configId: source.id, + totalNumberOfIssues: source.totalNumberOfIssues, + groups: source.groups?.flatMap((group) => this.convertGroup(group)), + priceSummary: this.converterService.convert( + prices, + VARIANT_CONFIGURATOR_PRICE_SUMMARY_NORMALIZER + ), + productCode: source.productCode, + }; + return resultTarget; + } + + convertGroup( + source: OccConfigurator.GroupOverview + ): Configurator.GroupOverview[] { + const result: Configurator.GroupOverview[] = []; + const characteristicValues: OccConfigurator.CharacteristicOverview[] = + source.characteristicValues; + const subGroups: OccConfigurator.GroupOverview[] = source.subGroups; + + result.push({ + id: source.id, + groupDescription: source.groupDescription, + attributes: characteristicValues + ? characteristicValues.map((characteristic) => { + return { + attribute: characteristic.characteristic, + value: characteristic.value, + }; + }) + : [], + }); + this.setGeneralDescription(result[0]); + if (subGroups) { + subGroups.forEach((subGroup) => + this.convertGroup(subGroup).forEach((groupArray) => + result.push(groupArray) + ) + ); + } + return result; + } + + setGeneralDescription(group: Configurator.GroupOverview): void { + if (group.id !== '_GEN') { + return; + } + this.translation + .translate('configurator.group.general') + .pipe(take(1)) + .subscribe((generalText) => (group.groupDescription = generalText)); + } +} diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-price-summary-normalizer.spec.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-price-summary-normalizer.spec.ts new file mode 100644 index 00000000000..6b856309c6b --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-price-summary-normalizer.spec.ts @@ -0,0 +1,56 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ConverterService } from '@spartacus/core'; +import { OccConfigurator } from '../variant-configurator-occ.models'; +import { OccConfiguratorVariantPriceSummaryNormalizer } from './occ-configurator-variant-price-summary-normalizer'; + +class MockConverterService { + convert() {} +} + +const CONFIG_ID = 'configId1234'; + +const prices: OccConfigurator.Prices = { + configId: CONFIG_ID, + pricingError: false, + showDeltaPrices: false, + priceSummary: { + basePrice: { + formattedValue: '22.000 €', + }, + selectedOptions: { + formattedValue: '900 €', + }, + currentTotal: { + formattedValue: '22.900 €', + }, + }, +}; + +describe('OccConfiguratorVariantPriceSummaryNormalizer', () => { + let occConfiguratorVariantPriceSummaryNormalizer: OccConfiguratorVariantPriceSummaryNormalizer; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OccConfiguratorVariantPriceSummaryNormalizer, + { provide: ConverterService, useClass: MockConverterService }, + ], + }); + + occConfiguratorVariantPriceSummaryNormalizer = TestBed.inject( + OccConfiguratorVariantPriceSummaryNormalizer as Type< + OccConfiguratorVariantPriceSummaryNormalizer + > + ); + }); + + it('should be created', () => { + expect(occConfiguratorVariantPriceSummaryNormalizer).toBeTruthy(); + }); + + it('should convert a price to a configuration', () => { + const result = occConfiguratorVariantPriceSummaryNormalizer.convert(prices); + expect(result).toEqual(prices.priceSummary); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-price-summary-normalizer.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-price-summary-normalizer.ts new file mode 100644 index 00000000000..5fea1d2d70b --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-price-summary-normalizer.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { OccConfigurator } from '../variant-configurator-occ.models'; +import { Configurator } from './../../../core/model/configurator.model'; + +@Injectable({ providedIn: 'root' }) +export class OccConfiguratorVariantPriceSummaryNormalizer + implements Converter { + convert( + source: OccConfigurator.Prices, + target?: Configurator.PriceSummary + ): Configurator.PriceSummary { + const resultTarget: Configurator.PriceSummary = { + ...target, + ...source.priceSummary, + }; + + return resultTarget; + } +} diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-serializer.spec.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-serializer.spec.ts new file mode 100644 index 00000000000..5d7e6a9da19 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-serializer.spec.ts @@ -0,0 +1,315 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { OccConfigurator } from '../variant-configurator-occ.models'; +import { Configurator } from './../../../core/model/configurator.model'; +import { OccConfiguratorVariantSerializer } from './occ-configurator-variant-serializer'; + +describe('OccConfiguratorVariantSerializer', () => { + let occConfiguratorVariantSerializer: OccConfiguratorVariantSerializer; + const GROUP_ID = '1-CPQ_LAPTOP.1'; + + const groupWithoutAttributes: Configurator.Group = { + id: GROUP_ID, + }; + + const groupWithSubGroup: Configurator.Group = { + id: GROUP_ID, + subGroups: [groupWithoutAttributes], + }; + + const sourceConfiguration: Configurator.Configuration = { + complete: false, + configId: '1234-56-7890', + consistent: true, + productCode: 'CPQ_LAPTOP', + groups: [ + { + configurable: true, + description: 'Core components', + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + id: GROUP_ID, + name: '1', + attributes: [ + { + label: 'Expected Number', + name: 'EXP_NUMBER', + required: true, + uiType: Configurator.UiType.NOT_IMPLEMENTED, + values: [], + }, + { + label: 'Processor', + name: 'CPQ_CPU', + required: true, + selectedSingleValue: 'INTELI5_35', + uiType: Configurator.UiType.RADIOBUTTON, + values: [], + }, + { + label: 'RAM', + name: 'CPQ_RAM', + required: false, + selectedSingleValue: '32GB', + uiType: Configurator.UiType.SINGLE_SELECTION_IMAGE, + values: [], + }, + ], + }, + { + configurable: true, + description: 'Peripherals & Accessories', + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + id: '1-CPQ_LAPTOP.2', + name: '2', + attributes: [], + }, + { + configurable: true, + description: 'Software', + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + id: '1-CPQ_LAPTOP.3', + name: '3', + attributes: [], + }, + ], + }; + + const targetOccConfiguration: OccConfigurator.Configuration = { + complete: false, + configId: '1234-56-7890', + groups: [ + { + configurable: true, + description: 'Core components', + groupType: OccConfigurator.GroupType.CSTIC_GROUP, + id: '1-CPQ_LAPTOP.1', + name: '1', + attributes: [ + { + name: 'EXP_NUMBER', + langDepName: 'Expected Number', + required: true, + type: OccConfigurator.UiType.NOT_IMPLEMENTED, + }, + + { + name: 'CPQ_CPU', + langDepName: 'Processor', + required: true, + type: OccConfigurator.UiType.RADIO_BUTTON, + value: 'INTELI5_35', + }, + { + name: 'CPQ_RAM', + langDepName: 'RAM', + required: false, + type: OccConfigurator.UiType.SINGLE_SELECTION_IMAGE, + value: '32GB', + }, + ], + }, + { + configurable: true, + description: 'Peripherals & Accessories', + groupType: OccConfigurator.GroupType.CSTIC_GROUP, + id: '1-CPQ_LAPTOP.2', + name: '2', + attributes: [], + }, + { + configurable: true, + description: 'Software', + groupType: OccConfigurator.GroupType.CSTIC_GROUP, + id: '1-CPQ_LAPTOP.3', + name: '3', + attributes: [], + }, + ], + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [OccConfiguratorVariantSerializer], + }); + + occConfiguratorVariantSerializer = TestBed.inject( + OccConfiguratorVariantSerializer as Type + ); + }); + + it('should convert configuration to occConfiguration', () => { + const convertedConfiguration = occConfiguratorVariantSerializer.convert( + sourceConfiguration + ); + expect(convertedConfiguration.complete).toEqual( + targetOccConfiguration.complete + ); + expect(convertedConfiguration.configId).toEqual( + targetOccConfiguration.configId + ); + }); + + it('should convert groups', () => { + const occGroups: OccConfigurator.Group[] = []; + occConfiguratorVariantSerializer.convertGroup( + sourceConfiguration.groups[0], + occGroups + ); + expect(occGroups.length).toBe(1); + expect(occGroups[0].id).toBe(GROUP_ID); + }); + + it('should handle groups without attributes well', () => { + const occGroups: OccConfigurator.Group[] = []; + occConfiguratorVariantSerializer.convertGroup( + groupWithoutAttributes, + occGroups + ); + expect(occGroups.length).toBe(1); + }); + + it('should take sub groups into account', () => { + const occGroups: OccConfigurator.Group[] = []; + + occConfiguratorVariantSerializer.convertGroup(groupWithSubGroup, occGroups); + expect(occGroups.length).toBe(1); + expect(occGroups[0].subGroups.length).toBe(1); + expect(occGroups[0].subGroups[0].id).toBe(GROUP_ID); + }); + + it('should map group types properly', () => { + expect( + occConfiguratorVariantSerializer.convertGroupType( + Configurator.GroupType.ATTRIBUTE_GROUP + ) + ).toBe(OccConfigurator.GroupType.CSTIC_GROUP); + + expect( + occConfiguratorVariantSerializer.convertGroupType( + Configurator.GroupType.SUB_ITEM_GROUP + ) + ).toBe(OccConfigurator.GroupType.INSTANCE); + }); + + it('should fill formatted value for numeric attributes', () => { + const numericAttribute: Configurator.Attribute = { + name: 'attr', + userInput: '12.21', + retractTriggered: false, + uiType: Configurator.UiType.NUMERIC, + }; + const occAttributes = []; + occConfiguratorVariantSerializer.convertAttribute( + numericAttribute, + occAttributes + ); + expect(occAttributes[0].formattedValue).toBe(numericAttribute.userInput); + expect(occAttributes[0].retractTriggered).toBe(false); + }); + + it('should fill value for string attributes', () => { + const stringAttribute: Configurator.Attribute = { + name: 'attr', + userInput: 'abc', + retractTriggered: false, + uiType: Configurator.UiType.STRING, + }; + const occAttributes = []; + occConfiguratorVariantSerializer.convertAttribute( + stringAttribute, + occAttributes + ); + expect(occAttributes[0].value).toBe(stringAttribute.userInput); + expect(occAttributes[0].retractTriggered).toBe(false); + }); + + it('should fill domainvalues for multivalued attributes', () => { + const mvAttribute: Configurator.Attribute = { + name: 'attr', + userInput: '', + retractTriggered: false, + uiType: Configurator.UiType.CHECKBOX, + values: [ + { valueCode: 'code1', valueDisplay: 'name1' }, + { valueCode: 'code2', valueDisplay: 'name2' }, + ], + }; + const occAttributes = []; + occConfiguratorVariantSerializer.convertAttribute( + mvAttribute, + occAttributes + ); + expect(occAttributes[0].domainValues.length).toBe(2); + expect(occAttributes[0].domainValues[0].key).toBe('code1'); + expect(occAttributes[0].domainValues[1].langDepName).toBe('name2'); + }); + + it('should consider that an attribute was retracted', () => { + const attributeWithRetraction: Configurator.Attribute = { + name: 'attr', + retractTriggered: true, + }; + const occAttributes = []; + occConfiguratorVariantSerializer.convertAttribute( + attributeWithRetraction, + occAttributes + ); + expect(occAttributes[0].retractTriggered).toBe(true); + }); + + it('should map ui types properly', () => { + expect( + occConfiguratorVariantSerializer.convertCharacteristicType( + Configurator.UiType.NUMERIC + ) + ).toBe(OccConfigurator.UiType.NUMERIC); + + expect( + occConfiguratorVariantSerializer.convertCharacteristicType( + Configurator.UiType.RADIOBUTTON + ) + ).toBe(OccConfigurator.UiType.RADIO_BUTTON); + + expect( + occConfiguratorVariantSerializer.convertCharacteristicType( + Configurator.UiType.READ_ONLY + ) + ).toBe(OccConfigurator.UiType.NOT_IMPLEMENTED); + + expect( + occConfiguratorVariantSerializer.convertCharacteristicType( + Configurator.UiType.DROPDOWN + ) + ).toBe(OccConfigurator.UiType.DROPDOWN); + + expect( + occConfiguratorVariantSerializer.convertCharacteristicType( + Configurator.UiType.STRING + ) + ).toBe(OccConfigurator.UiType.STRING); + + expect( + occConfiguratorVariantSerializer.convertCharacteristicType( + Configurator.UiType.CHECKBOXLIST + ) + ).toBe(OccConfigurator.UiType.CHECK_BOX_LIST); + + expect( + occConfiguratorVariantSerializer.convertCharacteristicType( + Configurator.UiType.CHECKBOX + ) + ).toBe(OccConfigurator.UiType.CHECK_BOX); + + expect( + occConfiguratorVariantSerializer.convertCharacteristicType( + Configurator.UiType.MULTI_SELECTION_IMAGE + ) + ).toBe(OccConfigurator.UiType.MULTI_SELECTION_IMAGE); + + expect( + occConfiguratorVariantSerializer.convertCharacteristicType( + Configurator.UiType.SINGLE_SELECTION_IMAGE + ) + ).toBe(OccConfigurator.UiType.SINGLE_SELECTION_IMAGE); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-serializer.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-serializer.ts new file mode 100644 index 00000000000..ba5d45b994c --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-serializer.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { OccConfigurator } from '../variant-configurator-occ.models'; +import { Configurator } from './../../../core/model/configurator.model'; + +@Injectable({ providedIn: 'root' }) +export class OccConfiguratorVariantSerializer + implements + Converter { + convert( + source: Configurator.Configuration, + target?: OccConfigurator.Configuration + ): OccConfigurator.Configuration { + const resultTarget: OccConfigurator.Configuration = { + ...target, + configId: source.configId, + complete: source.complete, + groups: [], + }; + + source.groups.forEach((group) => + this.convertGroup(group, resultTarget.groups) + ); + + return resultTarget; + } + + convertGroup(source: Configurator.Group, occGroups: OccConfigurator.Group[]) { + const group: OccConfigurator.Group = { + name: source.name, + id: source.id, + configurable: source.configurable, + groupType: this.convertGroupType(source.groupType), + description: source.description, + attributes: [], + subGroups: [], + }; + if (source.attributes) { + source.attributes.forEach((attribute) => + this.convertAttribute(attribute, group.attributes) + ); + } + if (source.subGroups) { + source.subGroups.forEach((subGroup) => + this.convertGroup(subGroup, group.subGroups) + ); + } + + occGroups.push(group); + } + + convertAttribute( + attribute: Configurator.Attribute, + occAttributes: OccConfigurator.Attribute[] + ): void { + const targetAttribute: OccConfigurator.Attribute = { + name: attribute.name, + langDepName: attribute.label, + required: attribute.required, + retractTriggered: attribute.retractTriggered, + type: this.convertCharacteristicType(attribute.uiType), + }; + + if ( + attribute.uiType === Configurator.UiType.DROPDOWN || + attribute.uiType === Configurator.UiType.RADIOBUTTON || + attribute.uiType === Configurator.UiType.SINGLE_SELECTION_IMAGE + ) { + targetAttribute.value = attribute.selectedSingleValue; + } else if (attribute.uiType === Configurator.UiType.STRING) { + targetAttribute.value = attribute.userInput; + } else if (attribute.uiType === Configurator.UiType.NUMERIC) { + targetAttribute.formattedValue = attribute.userInput; + } else if ( + attribute.uiType === Configurator.UiType.CHECKBOXLIST || + attribute.uiType === Configurator.UiType.CHECKBOX || + attribute.uiType === Configurator.UiType.MULTI_SELECTION_IMAGE + ) { + targetAttribute.domainValues = []; + attribute.values.forEach((value) => { + this.convertValue(value, targetAttribute.domainValues); + }); + } + + occAttributes.push(targetAttribute); + } + + convertValue(value: Configurator.Value, values: OccConfigurator.Value[]) { + values.push({ + key: value.valueCode, + langDepName: value.valueDisplay, + name: value.name, + selected: value.selected, + }); + } + + convertCharacteristicType(type: Configurator.UiType): OccConfigurator.UiType { + let uiType: OccConfigurator.UiType; + switch (type) { + case Configurator.UiType.RADIOBUTTON: { + uiType = OccConfigurator.UiType.RADIO_BUTTON; + break; + } + case Configurator.UiType.DROPDOWN: { + uiType = OccConfigurator.UiType.DROPDOWN; + break; + } + case Configurator.UiType.STRING: { + uiType = OccConfigurator.UiType.STRING; + break; + } + case Configurator.UiType.NUMERIC: { + uiType = OccConfigurator.UiType.NUMERIC; + break; + } + case Configurator.UiType.CHECKBOX: { + uiType = OccConfigurator.UiType.CHECK_BOX; + break; + } + case Configurator.UiType.CHECKBOXLIST: { + uiType = OccConfigurator.UiType.CHECK_BOX_LIST; + break; + } + case Configurator.UiType.MULTI_SELECTION_IMAGE: { + uiType = OccConfigurator.UiType.MULTI_SELECTION_IMAGE; + break; + } + case Configurator.UiType.SINGLE_SELECTION_IMAGE: { + uiType = OccConfigurator.UiType.SINGLE_SELECTION_IMAGE; + break; + } + default: { + uiType = OccConfigurator.UiType.NOT_IMPLEMENTED; + } + } + return uiType; + } + + convertGroupType( + groupType: Configurator.GroupType + ): OccConfigurator.GroupType { + switch (groupType) { + case Configurator.GroupType.ATTRIBUTE_GROUP: + return OccConfigurator.GroupType.CSTIC_GROUP; + case Configurator.GroupType.SUB_ITEM_GROUP: + return OccConfigurator.GroupType.INSTANCE; + } + } +} diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-update-cart-entry-serializer.spec.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-update-cart-entry-serializer.spec.ts new file mode 100644 index 00000000000..3f2ace248b7 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-update-cart-entry-serializer.spec.ts @@ -0,0 +1,59 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { OccConfigurator } from '../variant-configurator-occ.models'; +import { Configurator } from './../../../core/model/configurator.model'; +import { OccConfiguratorVariantUpdateCartEntrySerializer } from './occ-configurator-variant-update-cart-entry-serializer'; + +describe('OccConfiguratorVariantUpdateCartEntrySerializer', () => { + let occConfiguratorVariantUpdateCartEntrySerializer: OccConfiguratorVariantUpdateCartEntrySerializer; + + const USER_ID = 'theUser'; + const CART_ID = '98876'; + const PRODUCT_CODE = 'CPQ_LAPTOP'; + const QUANTITY = 1; + const CONFIG_ID = '12314'; + const ENTRY_NUMBER = '12314'; + const CONFIGURATOR_TYPE = 'CPQCONFIGURATOR'; + + const sourceParameters: Configurator.UpdateConfigurationForCartEntryParameters = { + userId: USER_ID, + cartId: CART_ID, + configuration: { productCode: PRODUCT_CODE, configId: CONFIG_ID }, + }; + + const targetParameters: OccConfigurator.UpdateConfigurationForCartEntryParameters = { + userId: USER_ID, + cartId: CART_ID, + product: { code: PRODUCT_CODE }, + quantity: QUANTITY, + configId: CONFIG_ID, + entryNumber: ENTRY_NUMBER, + configurationInfos: [{ configuratorType: CONFIGURATOR_TYPE }], + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [OccConfiguratorVariantUpdateCartEntrySerializer], + }); + + occConfiguratorVariantUpdateCartEntrySerializer = TestBed.inject( + OccConfiguratorVariantUpdateCartEntrySerializer as Type< + OccConfiguratorVariantUpdateCartEntrySerializer + > + ); + }); + + it('should convert updateCartEntry parameters to occ updateConfigurationForCartEntryParameters', () => { + const convertedParameters = occConfiguratorVariantUpdateCartEntrySerializer.convert( + sourceParameters + ); + expect(convertedParameters.userId).toEqual(targetParameters.userId); + expect(convertedParameters.configId).toEqual(targetParameters.configId); + expect(convertedParameters.product.code).toEqual( + targetParameters.product.code + ); + expect(convertedParameters.configurationInfos[0].configuratorType).toEqual( + CONFIGURATOR_TYPE + ); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-update-cart-entry-serializer.ts b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-update-cart-entry-serializer.ts new file mode 100644 index 00000000000..67922037a06 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/converters/occ-configurator-variant-update-cart-entry-serializer.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { OccConfigurator } from '../variant-configurator-occ.models'; +import { Configurator } from './../../../core/model/configurator.model'; + +@Injectable({ providedIn: 'root' }) +export class OccConfiguratorVariantUpdateCartEntrySerializer + implements + Converter< + Configurator.UpdateConfigurationForCartEntryParameters, + OccConfigurator.UpdateConfigurationForCartEntryParameters + > { + convert( + source: Configurator.UpdateConfigurationForCartEntryParameters, + target?: OccConfigurator.UpdateConfigurationForCartEntryParameters + ): OccConfigurator.UpdateConfigurationForCartEntryParameters { + const resultTarget: OccConfigurator.UpdateConfigurationForCartEntryParameters = { + ...target, + userId: source.userId, + cartId: source.cartId, + product: { code: source.configuration.productCode }, + entryNumber: source.cartEntryNumber, + configId: source.configuration.configId, + configurationInfos: [{ configuratorType: 'CPQCONFIGURATOR' }], + }; + + return resultTarget; + } +} diff --git a/feature-libs/product-configurator/rulebased/occ/variant/default-occ-configurator-variant-config.ts b/feature-libs/product-configurator/rulebased/occ/variant/default-occ-configurator-variant-config.ts new file mode 100644 index 00000000000..0aee08d9464 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/default-occ-configurator-variant-config.ts @@ -0,0 +1,36 @@ +import { OccConfig } from '@spartacus/core'; + +export function defaultOccVariantConfiguratorConfigFactory(): OccConfig { + return { + backend: { + occ: { + endpoints: { + createVariantConfiguration: + 'products/${productCode}/configurators/ccpconfigurator', + + readVariantConfiguration: 'ccpconfigurator/${configId}', + + updateVariantConfiguration: 'ccpconfigurator/${configId}', + + addVariantConfigurationToCart: + 'users/${userId}/carts/${cartId}/entries/ccpconfigurator', + + readVariantConfigurationForCartEntry: + 'users/${userId}/carts/${cartId}/entries/${cartEntryNumber}/ccpconfigurator', + + updateVariantConfigurationForCartEntry: + 'users/${userId}/carts/${cartId}/entries/${cartEntryNumber}/ccpconfigurator', + + readVariantConfigurationOverviewForOrderEntry: + 'users/${userId}/orders/${orderId}/entries/${orderEntryNumber}/ccpconfigurator/configurationOverview', + + readVariantConfigurationPriceSummary: + 'ccpconfigurator/${configId}/pricing', + + getVariantConfigurationOverview: + 'ccpconfigurator/${configId}/configurationOverview', + }, + }, + }, + }; +} diff --git a/feature-libs/product-configurator/rulebased/occ/variant/index.ts b/feature-libs/product-configurator/rulebased/occ/variant/index.ts new file mode 100644 index 00000000000..d5d496c150d --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/index.ts @@ -0,0 +1,4 @@ +export * from './converters/index'; +export * from './variant-configurator-occ.adapter'; +export * from './variant-configurator-occ.converters'; +export * from './variant-configurator-occ.module'; diff --git a/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.adapter.spec.ts b/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.adapter.spec.ts new file mode 100644 index 00000000000..069f448da64 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.adapter.spec.ts @@ -0,0 +1,401 @@ +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ConverterService, OccEndpointsService } from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { CART_MODIFICATION_NORMALIZER } from 'projects/core/src/cart'; +import { of } from 'rxjs'; +import { VariantConfiguratorOccAdapter } from '.'; +import { Configurator } from '../../core/model/configurator.model'; +import { + VARIANT_CONFIGURATOR_NORMALIZER, + VARIANT_CONFIGURATOR_OVERVIEW_NORMALIZER, + VARIANT_CONFIGURATOR_PRICE_SUMMARY_NORMALIZER, + VARIANT_CONFIGURATOR_SERIALIZER, +} from './variant-configurator-occ.converters'; +import { OccConfigurator } from './variant-configurator-occ.models'; + +class MockOccEndpointsService { + getUrl(endpoint: string, _urlParams?: object, _queryParams?: object) { + return this.getEndpoint(endpoint); + } + getEndpoint(url: string) { + return url; + } +} +const productCode = 'CONF_LAPTOP'; +const cartEntryNo = '1'; +const configId = '1234-56-7890'; +const groupId = 'GROUP1'; +const documentEntryNumber = '3'; +const userId = 'Anony'; +const documentId = '82736353'; + +const productConfiguration: Configurator.Configuration = { + configId: configId, + productCode: productCode, + owner: { + type: CommonConfigurator.OwnerType.PRODUCT, + id: productCode, + }, +}; + +const productConfigurationForCartEntry: Configurator.Configuration = { + configId: configId, + productCode: productCode, + owner: { + type: CommonConfigurator.OwnerType.CART_ENTRY, + id: cartEntryNo, + }, +}; + +const overview: OccConfigurator.Overview = { id: configId }; + +describe('OccConfigurationVariantAdapter', () => { + let occConfiguratorVariantAdapter: VariantConfiguratorOccAdapter; + let httpMock: HttpTestingController; + let converterService: ConverterService; + let occEnpointsService: OccEndpointsService; + let configuratorUtils: CommonConfiguratorUtilsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + VariantConfiguratorOccAdapter, + { provide: OccEndpointsService, useClass: MockOccEndpointsService }, + ], + }); + + httpMock = TestBed.inject( + HttpTestingController as Type + ); + converterService = TestBed.inject( + ConverterService as Type + ); + occEnpointsService = TestBed.inject( + OccEndpointsService as Type + ); + + occConfiguratorVariantAdapter = TestBed.inject( + VariantConfiguratorOccAdapter as Type + ); + configuratorUtils = TestBed.inject( + CommonConfiguratorUtilsService as Type + ); + configuratorUtils.setOwnerKey(productConfiguration.owner); + + spyOn(converterService, 'convert').and.callThrough(); + spyOn(occEnpointsService, 'getUrl').and.callThrough(); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should call createConfiguration endpoint', (done) => { + spyOn(converterService, 'pipeable').and.callThrough(); + occConfiguratorVariantAdapter + .createConfiguration(productConfiguration.owner) + .subscribe(); + done(); + + const mockReq = httpMock.expectOne((req) => { + return req.method === 'GET' && req.url === 'createVariantConfiguration'; + }); + + expect(occEnpointsService.getUrl).toHaveBeenCalledWith( + 'createVariantConfiguration', + { + productCode, + } + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + VARIANT_CONFIGURATOR_NORMALIZER + ); + mockReq.flush(productConfiguration); + }); + + it('should call readConfiguration endpoint', (done) => { + spyOn(converterService, 'pipeable').and.callThrough(); + occConfiguratorVariantAdapter + .readConfiguration(configId, groupId, productConfiguration.owner) + .subscribe(); + done(); + + const mockReq = httpMock.expectOne((req) => { + return req.method === 'GET' && req.url === 'readVariantConfiguration'; + }); + + expect(occEnpointsService.getUrl).toHaveBeenCalledWith( + 'readVariantConfiguration', + { configId }, + { groupId } + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + VARIANT_CONFIGURATOR_NORMALIZER + ); + mockReq.flush(productConfiguration); + }); + + it('should call updateConfiguration endpoint', (done) => { + spyOn(converterService, 'pipeable').and.callThrough(); + occConfiguratorVariantAdapter + .updateConfiguration(productConfiguration) + .subscribe(); + done(); + + const mockReq = httpMock.expectOne((req) => { + return req.method === 'PATCH' && req.url === 'updateVariantConfiguration'; + }); + + expect(occEnpointsService.getUrl).toHaveBeenCalledWith( + 'updateVariantConfiguration', + { + configId, + } + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + VARIANT_CONFIGURATOR_NORMALIZER + ); + expect(converterService.convert).toHaveBeenCalledWith( + productConfiguration, + VARIANT_CONFIGURATOR_SERIALIZER + ); + mockReq.flush(productConfiguration); + }); + + it('should call readPriceSummary endpoint', (done) => { + spyOn(converterService, 'pipeable').and.callThrough(); + occConfiguratorVariantAdapter + .readPriceSummary(productConfiguration) + .subscribe(); + done(); + + const mockReq = httpMock.expectOne((req) => { + return ( + req.method === 'GET' && + req.url === 'readVariantConfigurationPriceSummary' + ); + }); + + expect(occEnpointsService.getUrl).toHaveBeenCalledWith( + 'readVariantConfigurationPriceSummary', + { + configId, + } + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + VARIANT_CONFIGURATOR_PRICE_SUMMARY_NORMALIZER + ); + const priceSummary: OccConfigurator.Prices = {}; + mockReq.flush(priceSummary); + }); + + it('should call readConfigurationForCartEntry endpoint', (done) => { + spyOn(converterService, 'pipeable').and.callThrough(); + const params: CommonConfigurator.ReadConfigurationFromCartEntryParameters = { + owner: productConfiguration.owner, + userId: userId, + cartId: documentId, + cartEntryNumber: documentEntryNumber, + }; + occConfiguratorVariantAdapter + .readConfigurationForCartEntry(params) + .subscribe(); + done(); + + const mockReq = httpMock.expectOne((req) => { + return ( + req.method === 'GET' && + req.url === 'readVariantConfigurationForCartEntry' + ); + }); + + expect(occEnpointsService.getUrl).toHaveBeenCalledWith( + 'readVariantConfigurationForCartEntry', + { + userId, + cartId: documentId, + cartEntryNumber: documentEntryNumber, + } + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + VARIANT_CONFIGURATOR_NORMALIZER + ); + mockReq.flush(productConfiguration); + }); + + it('should call readVariantConfigurationOverviewForOrderEntry endpoint', (done) => { + spyOn(converterService, 'pipeable').and.callThrough(); + const params: CommonConfigurator.ReadConfigurationFromOrderEntryParameters = { + owner: productConfiguration.owner, + userId: userId, + orderId: documentId, + orderEntryNumber: documentEntryNumber, + }; + occConfiguratorVariantAdapter + .readConfigurationForOrderEntry(params) + .subscribe(); + done(); + + const mockReq = httpMock.expectOne((req) => { + return ( + req.method === 'GET' && + req.url === 'readVariantConfigurationOverviewForOrderEntry' + ); + }); + + expect(occEnpointsService.getUrl).toHaveBeenCalledWith( + 'readVariantConfigurationOverviewForOrderEntry', + { + userId, + orderId: documentId, + orderEntryNumber: documentEntryNumber, + } + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + VARIANT_CONFIGURATOR_OVERVIEW_NORMALIZER + ); + mockReq.flush(overview); + }); + + it('should call updateVariantConfigurationForCartEntry endpoint', (done) => { + spyOn(converterService, 'pipeable').and.callThrough(); + const params: Configurator.UpdateConfigurationForCartEntryParameters = { + configuration: productConfiguration, + userId: userId, + cartId: documentId, + cartEntryNumber: documentEntryNumber, + }; + occConfiguratorVariantAdapter + .updateConfigurationForCartEntry(params) + .subscribe(); + done(); + const mockReq = httpMock.expectOne((req) => { + return ( + req.method === 'PUT' && + req.url === 'updateVariantConfigurationForCartEntry' + ); + }); + + expect(occEnpointsService.getUrl).toHaveBeenCalledWith( + 'updateVariantConfigurationForCartEntry', + { + userId, + cartId: documentId, + cartEntryNumber: documentEntryNumber, + } + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + CART_MODIFICATION_NORMALIZER + ); + }); + + it('should call addToCart endpoint', (done) => { + spyOn(converterService, 'pipeable').and.callThrough(); + const params: Configurator.AddToCartParameters = { + productCode: 'Product', + quantity: 1, + configId: configId, + owner: productConfiguration.owner, + userId: userId, + cartId: documentId, + }; + occConfiguratorVariantAdapter.addToCart(params).subscribe(); + done(); + + const mockReq = httpMock.expectOne((req) => { + return ( + req.method === 'POST' && req.url === 'addVariantConfigurationToCart' + ); + }); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + CART_MODIFICATION_NORMALIZER + ); + }); + + it('should set owner on readVariantConfigurationForCartEntry according to parameters', (done) => { + const params: CommonConfigurator.ReadConfigurationFromCartEntryParameters = { + owner: productConfigurationForCartEntry.owner, + userId: userId, + cartId: documentId, + cartEntryNumber: documentEntryNumber, + }; + spyOn(converterService, 'pipeable').and.returnValue(() => + of(productConfiguration) + ); + occConfiguratorVariantAdapter + .readConfigurationForCartEntry(params) + .subscribe((result) => { + const owner = result.owner; + expect(owner).toBeDefined(); + expect(owner.type).toBe(CommonConfigurator.OwnerType.CART_ENTRY); + expect(owner.key).toBeUndefined(); + done(); + }); + }); + + it('should call getVariantConfigurationOverview endpoint', (done) => { + spyOn(converterService, 'pipeable').and.callThrough(); + occConfiguratorVariantAdapter + .getConfigurationOverview(productConfiguration.configId) + .subscribe(); + done(); + const mockReq = httpMock.expectOne((req) => { + return ( + req.method === 'GET' && req.url === 'getVariantConfigurationOverview' + ); + }); + + expect(occEnpointsService.getUrl).toHaveBeenCalledWith( + 'getVariantConfigurationOverview', + { + configId, + } + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + VARIANT_CONFIGURATOR_OVERVIEW_NORMALIZER + ); + mockReq.flush(overview); + }); + + it('should return configurator type', () => { + expect(occConfiguratorVariantAdapter.getConfiguratorType()).toEqual( + 'CPQCONFIGURATOR' + ); + }); +}); diff --git a/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.adapter.ts b/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.adapter.ts new file mode 100644 index 00000000000..79b6101bffd --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.adapter.ts @@ -0,0 +1,254 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { + CartModification, + CART_MODIFICATION_NORMALIZER, + ConverterService, + OccEndpointsService, +} from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { RulebasedConfiguratorAdapter } from '../../core/connectors/rulebased-configurator.adapter'; +import { Configurator } from '../../core/model/configurator.model'; +import { + VARIANT_CONFIGURATOR_ADD_TO_CART_SERIALIZER, + VARIANT_CONFIGURATOR_NORMALIZER, + VARIANT_CONFIGURATOR_OVERVIEW_NORMALIZER, + VARIANT_CONFIGURATOR_PRICE_SUMMARY_NORMALIZER, + VARIANT_CONFIGURATOR_SERIALIZER, + VARIANT_CONFIGURATOR_UPDATE_CART_ENTRY_SERIALIZER, +} from './variant-configurator-occ.converters'; +import { OccConfigurator } from './variant-configurator-occ.models'; + +@Injectable() +export class VariantConfiguratorOccAdapter + implements RulebasedConfiguratorAdapter { + constructor( + protected http: HttpClient, + protected occEndpointsService: OccEndpointsService, + protected converterService: ConverterService + ) {} + + getConfiguratorType(): string { + return 'CPQCONFIGURATOR'; + } + + createConfiguration( + owner: CommonConfigurator.Owner + ): Observable { + const productCode = owner.id; + return this.http + .get( + this.occEndpointsService.getUrl('createVariantConfiguration', { + productCode, + }) + ) + .pipe( + this.converterService.pipeable(VARIANT_CONFIGURATOR_NORMALIZER), + map((resultConfiguration) => { + return { + ...resultConfiguration, + owner: owner, + }; + }) + ); + } + + readConfiguration( + configId: string, + groupId: string, + configurationOwner: CommonConfigurator.Owner + ): Observable { + return this.http + .get( + this.occEndpointsService.getUrl( + 'readVariantConfiguration', + { configId }, + { groupId: groupId } + ) + ) + .pipe( + this.converterService.pipeable(VARIANT_CONFIGURATOR_NORMALIZER), + map((resultConfiguration) => { + return { + ...resultConfiguration, + owner: configurationOwner, + }; + }) + ); + } + + updateConfiguration( + configuration: Configurator.Configuration + ): Observable { + const configId = configuration.configId; + const url = this.occEndpointsService.getUrl('updateVariantConfiguration', { + configId, + }); + const occConfiguration = this.converterService.convert( + configuration, + VARIANT_CONFIGURATOR_SERIALIZER + ); + + return this.http.patch(url, occConfiguration).pipe( + this.converterService.pipeable(VARIANT_CONFIGURATOR_NORMALIZER), + map((resultConfiguration) => { + return { + ...resultConfiguration, + owner: configuration.owner, + }; + }) + ); + } + + addToCart( + parameters: Configurator.AddToCartParameters + ): Observable { + const url = this.occEndpointsService.getUrl( + 'addVariantConfigurationToCart', + { + userId: parameters.userId, + cartId: parameters.cartId, + } + ); + + const occAddToCartParameters = this.converterService.convert( + parameters, + VARIANT_CONFIGURATOR_ADD_TO_CART_SERIALIZER + ); + + const headers = new HttpHeaders({ + 'Content-Type': 'application/json', + }); + + return this.http + .post(url, occAddToCartParameters, { headers }) + .pipe(this.converterService.pipeable(CART_MODIFICATION_NORMALIZER)); + } + + readConfigurationForCartEntry( + parameters: CommonConfigurator.ReadConfigurationFromCartEntryParameters + ): Observable { + const url = this.occEndpointsService.getUrl( + 'readVariantConfigurationForCartEntry', + { + userId: parameters.userId, + cartId: parameters.cartId, + cartEntryNumber: parameters.cartEntryNumber, + } + ); + + return this.http.get(url).pipe( + this.converterService.pipeable(VARIANT_CONFIGURATOR_NORMALIZER), + map((resultConfiguration) => { + return { + ...resultConfiguration, + owner: parameters.owner, + }; + }) + ); + } + + updateConfigurationForCartEntry( + parameters: Configurator.UpdateConfigurationForCartEntryParameters + ): Observable { + const url = this.occEndpointsService.getUrl( + 'updateVariantConfigurationForCartEntry', + { + userId: parameters.userId, + cartId: parameters.cartId, + cartEntryNumber: parameters.cartEntryNumber, + } + ); + + const headers = new HttpHeaders({ + 'Content-Type': 'application/json', + }); + + const occUpdateCartEntryParameters = this.converterService.convert( + parameters, + VARIANT_CONFIGURATOR_UPDATE_CART_ENTRY_SERIALIZER + ); + + return this.http + .put(url, occUpdateCartEntryParameters, { headers }) + .pipe(this.converterService.pipeable(CART_MODIFICATION_NORMALIZER)); + } + + readConfigurationForOrderEntry( + parameters: CommonConfigurator.ReadConfigurationFromOrderEntryParameters + ): Observable { + const url = this.occEndpointsService.getUrl( + 'readVariantConfigurationOverviewForOrderEntry', + { + userId: parameters.userId, + orderId: parameters.orderId, + orderEntryNumber: parameters.orderEntryNumber, + } + ); + + return this.http.get(url).pipe( + this.converterService.pipeable(VARIANT_CONFIGURATOR_OVERVIEW_NORMALIZER), + map((overview) => { + const configuration: Configurator.Configuration = { + configId: overview.configId, + overview: overview, + }; + return configuration; + }), + map((resultConfiguration) => { + return { + ...resultConfiguration, + owner: parameters.owner, + }; + }) + ); + } + + readPriceSummary( + configuration: Configurator.Configuration + ): Observable { + const url = this.occEndpointsService.getUrl( + 'readVariantConfigurationPriceSummary', + { + configId: configuration.configId, + } + ); + + return this.http.get(url).pipe( + this.converterService.pipeable( + VARIANT_CONFIGURATOR_PRICE_SUMMARY_NORMALIZER + ), + map((pricingResult) => { + const result: Configurator.Configuration = { + configId: configuration.configId, + priceSummary: pricingResult, + }; + return result; + }), + map((resultConfiguration) => { + return { + ...resultConfiguration, + owner: configuration.owner, + }; + }) + ); + } + getConfigurationOverview( + configId: string + ): Observable { + const url = this.occEndpointsService.getUrl( + 'getVariantConfigurationOverview', + { + configId, + } + ); + + return this.http + .get(url) + .pipe( + this.converterService.pipeable(VARIANT_CONFIGURATOR_OVERVIEW_NORMALIZER) + ); + } +} diff --git a/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.converters.ts b/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.converters.ts new file mode 100644 index 00000000000..9065d41413f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.converters.ts @@ -0,0 +1,34 @@ +import { InjectionToken } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { Configurator } from '../../core/model/configurator.model'; +import { OccConfigurator } from './variant-configurator-occ.models'; + +export const VARIANT_CONFIGURATOR_NORMALIZER = new InjectionToken< + Converter +>('VariantConfiguratorNormalizer'); + +export const VARIANT_CONFIGURATOR_SERIALIZER = new InjectionToken< + Converter +>('VariantConfiguratorSerializer'); + +export const VARIANT_CONFIGURATOR_PRICE_SUMMARY_NORMALIZER = new InjectionToken< + Converter +>('VariantConfiguratorPriceSummaryNormalizer'); + +export const VARIANT_CONFIGURATOR_ADD_TO_CART_SERIALIZER = new InjectionToken< + Converter< + Configurator.AddToCartParameters, + OccConfigurator.AddToCartParameters + > +>('VariantConfiguratorAddToCartSerializer'); + +export const VARIANT_CONFIGURATOR_UPDATE_CART_ENTRY_SERIALIZER = new InjectionToken< + Converter< + Configurator.UpdateConfigurationForCartEntryParameters, + OccConfigurator.UpdateConfigurationForCartEntryParameters + > +>('VariantConfiguratorUpdateCartEntrySerializer'); + +export const VARIANT_CONFIGURATOR_OVERVIEW_NORMALIZER = new InjectionToken< + Converter +>('VariantConfiguratorOverviewNormalizer'); diff --git a/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.models.ts b/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.models.ts new file mode 100644 index 00000000000..7f6c96dc2be --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.models.ts @@ -0,0 +1,173 @@ +export namespace OccConfigurator { + /** + * + * An interface representing the variant configuration consumed through OCC. + */ + export interface Configuration { + /** + * @member {string} [configId] + */ + configId?: string; + /** + * @member {boolean} [complete] + */ + complete?: boolean; + + totalNumberOfIssues?: number; + groups?: Group[]; + rootProduct?: string; + } + + export interface Prices { + configId?: string; + pricingError?: boolean; + showDeltaPrices?: boolean; + priceSummary?: PriceSummary; + } + + export interface PriceSummary { + basePrice?: PriceDetails; + currentTotal?: PriceDetails; + currentTotalSavings?: PriceSavingDetails; + selectedOptions?: PriceDetails; + } + + export interface PriceDetails { + currencyIso?: string; + formattedValue?: string; + value?: number; + } + + export interface PriceSavingDetails extends PriceDetails { + maxQuantity?: number; + minQuantity?: number; + } + + export interface Group { + configurable?: boolean; + complete?: boolean; + consistent?: boolean; + attributes?: Attribute[]; + description?: string; + groupType?: GroupType; + id?: string; + name?: string; + subGroups?: Group[]; + } + + export interface Attribute { + name?: string; + langDepName?: string; + type?: UiType; + domainValues?: Value[]; + required?: boolean; + value?: string; + key?: string; + formattedValue?: string; + maxlength?: number; + images?: Image[]; + typeLength?: number; + numberScale?: number; + negativeAllowed?: boolean; + conflicts?: string[]; + retractTriggered?: boolean; + } + + export interface Value { + key?: string; + name?: string; + langDepName?: string; + readonly?: boolean; + selected?: boolean; + images?: Image[]; + } + + export interface AddToCartParameters { + userId?: string; + cartId?: string; + product?: AddToCartProductData; + quantity?: number; + configId?: string; + } + + export interface UpdateConfigurationForCartEntryParameters { + userId?: string; + cartId?: string; + product?: AddToCartProductData; + quantity?: number; + configId: string; + entryNumber: string; + configurationInfos: ConfigurationInfo[]; + } + + export interface ConfigurationInfo { + configuratorType: string; + } + + export interface AddToCartProductData { + code?: string; + } + + export interface Overview { + id: string; + totalNumberOfIssues?: number; + groups?: GroupOverview[]; + pricing?: PriceSummary; + productCode?: string; + } + + export interface GroupOverview { + id?: string; + groupDescription?: string; + characteristicValues?: CharacteristicOverview[]; + subGroups?: GroupOverview[]; + } + + export interface CharacteristicOverview { + characteristic: string; + value: string; + } + export interface Image { + imageType?: ImageType; + format?: ImageFormatType; + url?: string; + altText?: string; + galleryIndex?: number; + } + + export enum GroupType { + CSTIC_GROUP = 'CSTIC_GROUP', + INSTANCE = 'INSTANCE', + CONFLICT_HEADER = 'CONFLICT_HEADER', + CONFLICT = 'CONFLICT', + } + + export enum UiType { + STRING = 'STRING', + NUMERIC = 'NUMERIC', + CHECK_BOX = 'CHECK_BOX', + CHECK_BOX_LIST = 'CHECK_BOX_LIST', + RADIO_BUTTON = 'RADIO_BUTTON', + RADIO_BUTTON_ADDITIONAL_INPUT = 'RADIO_BUTTON_ADDITIONAL_INPUT', + DROPDOWN = 'DROPDOWN', + DROPDOWN_ADDITIONAL_INPUT = 'DROPDOWN_ADDITIONAL_INPUT', + READ_ONLY = 'READ_ONLY', + NOT_IMPLEMENTED = 'NOT_IMPLEMENTED', + SINGLE_SELECTION_IMAGE = 'SINGLE_SELECTION_IMAGE', + MULTI_SELECTION_IMAGE = 'MULTI_SELECTION_IMAGE', + } + + export enum PriceType { + BUY = 'BUY', + } + + export enum ImageFormatType { + VALUE_IMAGE = 'VALUE_IMAGE', + CSTIC_IMAGE = 'CSTIC_IMAGE', + } + + export enum ImageType { + PRIMARY = 'PRIMARY', + GALLERY = 'GALLERY', + } +} diff --git a/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.module.ts b/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.module.ts new file mode 100644 index 00000000000..503703dfddf --- /dev/null +++ b/feature-libs/product-configurator/rulebased/occ/variant/variant-configurator-occ.module.ts @@ -0,0 +1,65 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { ConfigModule } from '@spartacus/core'; +import { RulebasedConfiguratorConnector } from '../../core/connectors/rulebased-configurator.connector'; +import { OccConfiguratorVariantAddToCartSerializer } from './converters/occ-configurator-variant-add-to-cart-serializer'; +import { OccConfiguratorVariantNormalizer } from './converters/occ-configurator-variant-normalizer'; +import { OccConfiguratorVariantOverviewNormalizer } from './converters/occ-configurator-variant-overview-normalizer'; +import { OccConfiguratorVariantPriceSummaryNormalizer } from './converters/occ-configurator-variant-price-summary-normalizer'; +import { OccConfiguratorVariantSerializer } from './converters/occ-configurator-variant-serializer'; +import { OccConfiguratorVariantUpdateCartEntrySerializer } from './converters/occ-configurator-variant-update-cart-entry-serializer'; +import { defaultOccVariantConfiguratorConfigFactory } from './default-occ-configurator-variant-config'; +import { VariantConfiguratorOccAdapter } from './variant-configurator-occ.adapter'; +import { + VARIANT_CONFIGURATOR_ADD_TO_CART_SERIALIZER, + VARIANT_CONFIGURATOR_NORMALIZER, + VARIANT_CONFIGURATOR_OVERVIEW_NORMALIZER, + VARIANT_CONFIGURATOR_PRICE_SUMMARY_NORMALIZER, + VARIANT_CONFIGURATOR_SERIALIZER, + VARIANT_CONFIGURATOR_UPDATE_CART_ENTRY_SERIALIZER, +} from './variant-configurator-occ.converters'; + +@NgModule({ + imports: [ + CommonModule, + ConfigModule.withConfigFactory(defaultOccVariantConfiguratorConfigFactory), + ], + providers: [ + { + provide: RulebasedConfiguratorConnector.CONFIGURATOR_ADAPTER_LIST, + useClass: VariantConfiguratorOccAdapter, + multi: true, + }, + { + provide: VARIANT_CONFIGURATOR_NORMALIZER, + useExisting: OccConfiguratorVariantNormalizer, + multi: true, + }, + { + provide: VARIANT_CONFIGURATOR_SERIALIZER, + useExisting: OccConfiguratorVariantSerializer, + multi: true, + }, + { + provide: VARIANT_CONFIGURATOR_PRICE_SUMMARY_NORMALIZER, + useExisting: OccConfiguratorVariantPriceSummaryNormalizer, + multi: true, + }, + { + provide: VARIANT_CONFIGURATOR_ADD_TO_CART_SERIALIZER, + useExisting: OccConfiguratorVariantAddToCartSerializer, + multi: true, + }, + { + provide: VARIANT_CONFIGURATOR_UPDATE_CART_ENTRY_SERIALIZER, + useExisting: OccConfiguratorVariantUpdateCartEntrySerializer, + multi: true, + }, + { + provide: VARIANT_CONFIGURATOR_OVERVIEW_NORMALIZER, + useExisting: OccConfiguratorVariantOverviewNormalizer, + multi: true, + }, + ], +}) +export class VariantConfiguratorOccModule {} diff --git a/feature-libs/product-configurator/rulebased/public_api.ts b/feature-libs/product-configurator/rulebased/public_api.ts new file mode 100644 index 00000000000..412818db91f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/public_api.ts @@ -0,0 +1,5 @@ +/* + * Public API Surface of the rule based configurator + */ + +export * from './index'; diff --git a/feature-libs/product-configurator/rulebased/root/default-rulebased-routing-config.ts b/feature-libs/product-configurator/rulebased/root/default-rulebased-routing-config.ts new file mode 100644 index 00000000000..6bfc659e153 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/root/default-rulebased-routing-config.ts @@ -0,0 +1,17 @@ +import { RoutingConfig } from '@spartacus/core'; + +export const defaultRulebasedRoutingConfig: RoutingConfig = { + routing: { + routes: { + configureCPQCONFIGURATOR: { + paths: ['configure/vc/:ownerType/entityKey/:entityKey'], + }, + configureOverviewCPQCONFIGURATOR: { + paths: [ + 'configure-overview/vc/:ownerType/entityKey/:entityKey/displayOnly/:displayOnly', + 'configure-overview/vc/:ownerType/entityKey/:entityKey', + ], + }, + }, + }, +}; diff --git a/feature-libs/product-configurator/rulebased/root/index.ts b/feature-libs/product-configurator/rulebased/root/index.ts new file mode 100644 index 00000000000..c57e575fae6 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/root/index.ts @@ -0,0 +1,4 @@ +export * from './rulebased-configurator-root-feature.module'; +export * from './rulebased-configurator-root.module'; +export * from './rulebased-configurator-routing.module'; +export * from './variant/index'; diff --git a/feature-libs/product-configurator/rulebased/root/ng-package.json b/feature-libs/product-configurator/rulebased/root/ng-package.json new file mode 100644 index 00000000000..23e0f90b954 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/root/ng-package.json @@ -0,0 +1,11 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts", + "umdModuleIds": { + "@spartacus/core": "core", + "@spartacus/storefront": "storefront", + "@ng-select/ng-select": "ngSelect" + } + } +} diff --git a/feature-libs/product-configurator/rulebased/root/public_api.ts b/feature-libs/product-configurator/rulebased/root/public_api.ts new file mode 100644 index 00000000000..b6d90b23917 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/root/public_api.ts @@ -0,0 +1,5 @@ +/* + * Public API Surface of the rule based root entry point + */ + +export * from './index'; diff --git a/feature-libs/product-configurator/rulebased/root/rulebased-configurator-root-feature.module.ts b/feature-libs/product-configurator/rulebased/root/rulebased-configurator-root-feature.module.ts new file mode 100644 index 00000000000..b77d55a5b68 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/root/rulebased-configurator-root-feature.module.ts @@ -0,0 +1,32 @@ +import { NgModule } from '@angular/core'; +import { provideDefaultConfig } from '@spartacus/core'; + +/** + * Contains feature module configuration + */ + +@NgModule({ + imports: [], + providers: [ + provideDefaultConfig({ + featureModules: { + rulebased: { + cmsComponents: [ + 'ConfiguratorForm', + 'ConfiguratorOverview', + 'ConfiguratorUpdateMessage', + 'ConfiguratorAddToCartButton', + 'ConfiguratorMenu', + 'ConfiguratorGroupTitle', + 'ConfiguratorOverviewBanner', + 'ConfiguratorPrevNext', + 'ConfiguratorPriceSummary', + 'ConfiguratorTitle', + 'ConfiguratorTabBar', + ], + }, + }, + }), + ], +}) +export class RulebasedConfiguratorRootFeatureModule {} diff --git a/feature-libs/product-configurator/rulebased/root/rulebased-configurator-root.module.ts b/feature-libs/product-configurator/rulebased/root/rulebased-configurator-root.module.ts new file mode 100644 index 00000000000..83a5eac0b18 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/root/rulebased-configurator-root.module.ts @@ -0,0 +1,29 @@ +import { CommonModule } from '@angular/common'; +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { CommonConfiguratorModule } from '@spartacus/product-configurator/common'; +import { RulebasedConfiguratorRootFeatureModule } from './rulebased-configurator-root-feature.module'; +import { RulebasedConfiguratorRoutingModule } from './rulebased-configurator-routing.module'; +import { VariantConfiguratorInteractiveModule } from './variant/variant-configurator-interactive.module'; +import { VariantConfiguratorOverviewModule } from './variant/variant-configurator-overview.module'; + +/** + * Exposes the root modules that we need to load statically. Contains page mappings, route configurations + * and feature configuration + */ +@NgModule({ + imports: [ + CommonModule, + CommonConfiguratorModule, + RulebasedConfiguratorRootFeatureModule, + VariantConfiguratorInteractiveModule, + VariantConfiguratorOverviewModule, + RulebasedConfiguratorRoutingModule.forRoot(), + ], +}) +export class RulebasedConfiguratorRootModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: RulebasedConfiguratorRootModule, + }; + } +} diff --git a/feature-libs/product-configurator/rulebased/root/rulebased-configurator-routing.module.ts b/feature-libs/product-configurator/rulebased/root/rulebased-configurator-routing.module.ts new file mode 100644 index 00000000000..4e9aeaf35a3 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/root/rulebased-configurator-routing.module.ts @@ -0,0 +1,22 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { + provideDefaultConfig, + RoutingModule as CoreRoutingModule, +} from '@spartacus/core'; +import { CmsRouteModule } from '@spartacus/storefront'; +import { defaultRulebasedRoutingConfig } from './default-rulebased-routing-config'; + +/** + * Provides the default cx routing configuration for the rulebased configurator + */ +@NgModule({ + imports: [CoreRoutingModule.forRoot(), CmsRouteModule], +}) +export class RulebasedConfiguratorRoutingModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: RulebasedConfiguratorRoutingModule, + providers: [provideDefaultConfig(defaultRulebasedRoutingConfig)], + }; + } +} diff --git a/feature-libs/product-configurator/rulebased/root/variant/index.ts b/feature-libs/product-configurator/rulebased/root/variant/index.ts new file mode 100644 index 00000000000..36bb77f4758 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/root/variant/index.ts @@ -0,0 +1,2 @@ +export * from './variant-configurator-interactive.module'; +export * from './variant-configurator-overview.module'; diff --git a/feature-libs/product-configurator/rulebased/root/variant/variant-configurator-interactive.module.ts b/feature-libs/product-configurator/rulebased/root/variant/variant-configurator-interactive.module.ts new file mode 100644 index 00000000000..044aada47df --- /dev/null +++ b/feature-libs/product-configurator/rulebased/root/variant/variant-configurator-interactive.module.ts @@ -0,0 +1,78 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { provideDefaultConfig } from '@spartacus/core'; +import { + CmsPageGuard, + HamburgerMenuModule, + LayoutConfig, + PageLayoutComponent, +} from '@spartacus/storefront'; + +/** + * Takes care of the interactive configuration process (the user enters new attribute values and navigates through the configuration). + * Provides routing, assignment of ng components to CMS components and assignment of CMS components to the layout slots + */ +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: null, + data: { + cxRoute: 'configureCPQCONFIGURATOR', + }, + component: PageLayoutComponent, + canActivate: [CmsPageGuard], + }, + ]), + HamburgerMenuModule, + ], + providers: [ + provideDefaultConfig({ + layoutSlots: { + VariantConfigurationTemplate: { + header: { + md: { + slots: [ + 'PreHeader', + 'SiteContext', + 'SiteLinks', + 'SiteLogo', + 'SearchBox', + 'SiteLogin', + 'MiniCart', + ], + }, + xs: { + slots: ['PreHeader', 'SiteLogo', 'SearchBox', 'MiniCart'], + }, + }, + + navigation: { + slots: [ + 'SiteLogin', + 'SiteContext', + 'SiteLinks', + 'VariantConfigMenu', + ], + }, + + lg: { + slots: [ + 'VariantConfigHeader', + 'VariantConfigMenu', + 'VariantConfigContent', + 'VariantConfigBottombar', + ], + }, + + slots: [ + 'VariantConfigHeader', + 'VariantConfigContent', + 'VariantConfigBottombar', + ], + }, + }, + }), + ], +}) +export class VariantConfiguratorInteractiveModule {} diff --git a/feature-libs/product-configurator/rulebased/root/variant/variant-configurator-overview.module.ts b/feature-libs/product-configurator/rulebased/root/variant/variant-configurator-overview.module.ts new file mode 100644 index 00000000000..38fcb6c429d --- /dev/null +++ b/feature-libs/product-configurator/rulebased/root/variant/variant-configurator-overview.module.ts @@ -0,0 +1,65 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { provideDefaultConfig } from '@spartacus/core'; +import { + CmsPageGuard, + LayoutConfig, + PageLayoutComponent, +} from '@spartacus/storefront'; + +/** + * Takes care of the configuration overview that visualizes the attribute value assignments that have been done already in a condensed, read-only form. + * The end-user can switch between the interactive view and this overview. + * Provides routing, assignment of ng components to CMS components and assignment of CMS components to the layout slots. + * Some of the ng components on this view (tab bar, price summary and addToCart button) are shared between the interactive view and the overview. + */ +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: null, + component: PageLayoutComponent, + data: { + cxRoute: 'configureOverviewCPQCONFIGURATOR', + }, + canActivate: [CmsPageGuard], + }, + ]), + ], + providers: [ + provideDefaultConfig({ + layoutSlots: { + VariantConfigurationOverviewTemplate: { + header: { + md: { + slots: [ + 'PreHeader', + 'SiteContext', + 'SiteLinks', + 'SiteLogo', + 'SearchBox', + 'SiteLogin', + 'MiniCart', + ], + }, + xs: { + slots: ['PreHeader', 'SiteLogo', 'SearchBox', 'MiniCart'], + }, + }, + navigation: { + xs: { + slots: ['SiteLogin', 'SiteContext', 'SiteLinks'], + }, + }, + slots: [ + 'VariantConfigOverviewHeader', + 'VariantConfigOverviewBanner', + 'VariantConfigOverviewContent', + 'VariantConfigOverviewBottombar', + ], + }, + }, + }), + ], +}) +export class VariantConfiguratorOverviewModule {} diff --git a/feature-libs/product-configurator/rulebased/rulebased-configurator.module.ts b/feature-libs/product-configurator/rulebased/rulebased-configurator.module.ts new file mode 100644 index 00000000000..9b4ccbc7f2c --- /dev/null +++ b/feature-libs/product-configurator/rulebased/rulebased-configurator.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { RulebasedConfiguratorComponentsModule } from './components/rulebased-configurator-components.module'; +import { RulebasedConfiguratorCoreModule } from './core/rulebased-configurator-core.module'; +import { VariantConfiguratorOccModule } from './occ/variant/variant-configurator-occ.module'; + +@NgModule({ + imports: [ + VariantConfiguratorOccModule, + RulebasedConfiguratorCoreModule, + RulebasedConfiguratorComponentsModule, + ], +}) +export class RulebasedConfiguratorModule {} diff --git a/feature-libs/product-configurator/rulebased/shared/testing/configurator-component-test-utils.service.ts b/feature-libs/product-configurator/rulebased/shared/testing/configurator-component-test-utils.service.ts new file mode 100644 index 00000000000..845d715b6d4 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/shared/testing/configurator-component-test-utils.service.ts @@ -0,0 +1,69 @@ +/** + * Configurator component test utils service provides helper functions for the component tests. + */ + +import { Configurator } from '../../core/model'; + +export class ConfiguratorComponentTestUtilsService { + /** + * Deep freezes a product configuration, used for testing purposed to ensure test + * data behaves read-only + * @param productConfiguration + */ + static freezeProductConfiguration( + productConfiguration: Configurator.Configuration + ) { + Object.freeze(productConfiguration); + Object.freeze(productConfiguration.interactionState); + Object.freeze(productConfiguration.owner); + Object.freeze(productConfiguration.nextOwner); + this.freezeOverview(productConfiguration.overview); + this.freezePriceSummary(productConfiguration.priceSummary); + productConfiguration.flatGroups?.forEach((group) => + this.freezeGroup(group) + ); + productConfiguration.groups?.forEach((group) => this.freezeGroup(group)); + } + + protected static freezeGroup(group: Configurator.Group) { + Object.freeze(group); + group.attributes?.forEach((attribute) => this.freezeAttribute(attribute)); + group.subGroups?.forEach((subGroup) => this.freezeGroup(subGroup)); + } + + protected static freezeAttribute(attribute: Configurator.Attribute) { + Object.freeze(attribute); + attribute.images?.forEach((image) => Object.freeze(image)); + attribute.values?.forEach((value) => this.freezeValue(value)); + } + + protected static freezeValue(value: Configurator.Value) { + Object.freeze(value); + value.images?.forEach((image) => Object.freeze(image)); + } + + static freezeOverview(overview: Configurator.Overview) { + if (overview) { + Object.freeze(overview); + this.freezePriceSummary(overview.priceSummary); + overview.groups?.forEach((ovGroup) => this.freezeOvGroup(ovGroup)); + } + } + + protected static freezeOvGroup(overviewGroup: Configurator.GroupOverview) { + Object.freeze(overviewGroup); + overviewGroup.attributes?.forEach((ovAttribute) => + Object.freeze(ovAttribute) + ); + } + + protected static freezePriceSummary(priceSummary: Configurator.PriceSummary) { + if (priceSummary) { + Object.freeze(priceSummary); + Object.freeze(priceSummary.basePrice); + Object.freeze(priceSummary.currentTotal); + Object.freeze(priceSummary.currentTotalSavings); + Object.freeze(priceSummary.selectedOptions); + } + } +} diff --git a/feature-libs/product-configurator/rulebased/shared/testing/configurator-test-data.ts b/feature-libs/product-configurator/rulebased/shared/testing/configurator-test-data.ts new file mode 100644 index 00000000000..1b47fcec01d --- /dev/null +++ b/feature-libs/product-configurator/rulebased/shared/testing/configurator-test-data.ts @@ -0,0 +1,511 @@ +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Configurator } from '../../../rulebased/core/model/configurator.model'; + +export const PRODUCT_CODE = 'CONF_LAPTOP'; +export const CONFIGURATOR_TYPE = 'cpqconfigurator'; +export const CONFIG_ID = '1234-56-7890'; + +export const GROUP_ID_1 = '1234-56-7891'; +export const GROUP_ID_2 = '1234-56-7892'; +export const GROUP_ID_3 = '1234-56-7893'; +export const GROUP_ID_4 = '1234-56-7894'; +export const GROUP_ID_5 = '1234-56-7895'; +export const GROUP_ID_6 = '1234-56-7896'; +export const GROUP_ID_7 = '1234-56-7897'; +export const GROUP_ID_8 = '1234-56-7898'; +export const GROUP_ID_9 = '1234-56-7899'; +export const GROUP_ID_10 = '1234-56-7900'; + +export const GROUP_ID_CONFLICT_HEADER = '9999-99-0000'; +export const GROUP_ID_CONFLICT_1 = '9999-99-0001'; +export const GROUP_ID_CONFLICT_2 = '9999-99-0002'; +export const GROUP_ID_CONFLICT_3 = '9999-99-0003'; +export const ATTRIBUTE_1_CHECKBOX = 'ATTRIBUTE_1_CHECKBOX'; + +export const CONFIGURATOR_ROUTE = 'configureCPQCONFIGURATOR'; + +export const mockRouterState: any = { + state: { + params: { + entityKey: PRODUCT_CODE, + ownerType: CommonConfigurator.OwnerType.PRODUCT, + }, + queryParams: {}, + semanticRoute: CONFIGURATOR_ROUTE, + }, +}; + +const groupsWithoutIssues: Configurator.Group = { + id: GROUP_ID_1, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + attributes: [ + { + name: ATTRIBUTE_1_CHECKBOX, + uiType: Configurator.UiType.CHECKBOXLIST, + required: true, + incomplete: false, + }, + ], + subGroups: [], +}; +export const productConfigurationWithoutIssues: Configurator.Configuration = { + configId: CONFIG_ID, + productCode: PRODUCT_CODE, + totalNumberOfIssues: 0, + owner: { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.PRODUCT, + }, + groups: [groupsWithoutIssues], + flatGroups: [groupsWithoutIssues], +}; + +export const productConfiguration: Configurator.Configuration = { + configId: CONFIG_ID, + productCode: PRODUCT_CODE, + groups: [ + { + id: GROUP_ID_1, + configurable: true, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + attributes: [ + { + name: ATTRIBUTE_1_CHECKBOX, + uiType: Configurator.UiType.CHECKBOXLIST, + required: true, + incomplete: true, + }, + ], + subGroups: [], + }, + + { + id: GROUP_ID_2, + configurable: true, + attributes: [ + { + name: 'ATTRIBUTE_2_RADIOBUTTON', + uiType: Configurator.UiType.RADIOBUTTON, + required: false, + incomplete: false, + }, + ], + subGroups: [], + }, + { + id: GROUP_ID_3, + configurable: true, + attributes: [ + { + name: 'ATTRIBUTE_3_SINGLESELECTIONIMAGE', + uiType: Configurator.UiType.SINGLE_SELECTION_IMAGE, + required: true, + incomplete: true, + }, + ], + subGroups: [ + { + id: GROUP_ID_4, + configurable: true, + subGroups: [], + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + attributes: [ + { + name: 'ATTRIBUTE_5_STRING', + uiType: Configurator.UiType.STRING, + required: true, + incomplete: false, + }, + { + name: 'ATTRIBUTE_5_DROPDOWN', + uiType: Configurator.UiType.DROPDOWN, + required: true, + incomplete: true, + }, + ], + }, + ], + }, + { + id: GROUP_ID_5, + configurable: true, + attributes: [ + { + name: 'ATTRIBUTE_5_STRING', + uiType: Configurator.UiType.STRING, + required: true, + incomplete: false, + }, + { + name: 'ATTRIBUTE_5_DROPDOWN', + uiType: Configurator.UiType.DROPDOWN, + required: true, + incomplete: true, + }, + ], + + subGroups: [ + { + id: GROUP_ID_6, + configurable: true, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + subGroups: [], + attributes: [ + { + name: 'ATTRIBUTE_3_SINGLESELECTIONIMAGE', + uiType: Configurator.UiType.SINGLE_SELECTION_IMAGE, + required: true, + incomplete: true, + }, + ], + }, + { + id: GROUP_ID_7, + subGroups: [ + { + id: GROUP_ID_8, + configurable: false, + subGroups: [], + attributes: [], + }, + ], + attributes: [], + }, + ], + }, + + { + id: GROUP_ID_9, + configurable: true, + subGroups: [ + { + id: GROUP_ID_10, + configurable: true, + attributes: [ + { + name: 'ATTRIBUTE_10_DROPDOWN', + uiType: Configurator.UiType.DROPDOWN, + required: true, + incomplete: false, + }, + ], + subGroups: [], + }, + ], + }, + ], + flatGroups: [ + { + id: GROUP_ID_1, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + subGroups: [], + attributes: [ + { + name: ATTRIBUTE_1_CHECKBOX, + uiType: Configurator.UiType.CHECKBOXLIST, + required: true, + incomplete: true, + }, + ], + }, + { id: GROUP_ID_2 }, + { id: GROUP_ID_4 }, + { id: GROUP_ID_6 }, + { id: GROUP_ID_7 }, + { id: GROUP_ID_10 }, + ], + owner: { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.PRODUCT, + configuratorType: CONFIGURATOR_TYPE, + }, + nextOwner: {}, + interactionState: { + currentGroup: GROUP_ID_2, + menuParentGroup: GROUP_ID_3, + groupsVisited: {}, + issueNavigationDone: true, + }, + overview: { + groups: [ + { + id: '1', + groupDescription: 'Group 1', + attributes: [ + { + attribute: 'C1', + value: 'V1', + }, + ], + }, + { + id: '2', + groupDescription: 'Group 2', + attributes: [ + { + attribute: 'C2', + value: 'V2', + }, + { + attribute: 'C3', + value: 'V3', + }, + ], + }, + ], + }, +}; + +export const productConfigurationWithConflicts: Configurator.Configuration = { + configId: CONFIG_ID, + productCode: PRODUCT_CODE, + totalNumberOfIssues: 3, + groups: [ + { + id: GROUP_ID_CONFLICT_HEADER, + groupType: Configurator.GroupType.CONFLICT_HEADER_GROUP, + attributes: [], + subGroups: [ + { + id: GROUP_ID_CONFLICT_1, + groupType: Configurator.GroupType.CONFLICT_GROUP, + subGroups: [], + attributes: [], + }, + { + id: GROUP_ID_CONFLICT_2, + groupType: Configurator.GroupType.CONFLICT_GROUP, + subGroups: [], + attributes: [], + }, + { + id: GROUP_ID_CONFLICT_3, + groupType: Configurator.GroupType.CONFLICT_GROUP, + subGroups: [], + attributes: [], + }, + ], + }, + { + id: GROUP_ID_1, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + attributes: [ + { + name: 'ATTRIBUTE_1_CHECKBOX', + uiType: Configurator.UiType.CHECKBOXLIST, + required: true, + incomplete: true, + }, + ], + subGroups: [], + }, + + { + id: GROUP_ID_2, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + attributes: [ + { + name: 'ATTRIBUTE_2_RADIOBUTTON', + uiType: Configurator.UiType.RADIOBUTTON, + required: false, + incomplete: false, + }, + ], + subGroups: [], + }, + { + id: GROUP_ID_3, + groupType: Configurator.GroupType.SUB_ITEM_GROUP, + attributes: [], + subGroups: [ + { + id: GROUP_ID_4, + subGroups: [], + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + attributes: [ + { + name: 'ATTRIBUTE_5_STRING', + uiType: Configurator.UiType.STRING, + required: true, + incomplete: false, + }, + { + name: 'ATTRIBUTE_5_DROPDOWN', + uiType: Configurator.UiType.DROPDOWN, + required: true, + incomplete: true, + }, + ], + }, + ], + }, + { + id: GROUP_ID_5, + attributes: [], + groupType: Configurator.GroupType.SUB_ITEM_GROUP, + subGroups: [ + { + id: GROUP_ID_6, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + subGroups: [], + attributes: [ + { + name: 'ATTRIBUTE_3_SINGLESELECTIONIMAGE', + uiType: Configurator.UiType.SINGLE_SELECTION_IMAGE, + required: true, + incomplete: true, + }, + ], + }, + { + id: GROUP_ID_7, + groupType: Configurator.GroupType.SUB_ITEM_GROUP, + subGroups: [ + { + id: GROUP_ID_8, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + subGroups: [], + attributes: [], + }, + ], + attributes: [], + }, + ], + }, + + { + id: GROUP_ID_9, + attributes: [], + groupType: Configurator.GroupType.SUB_ITEM_GROUP, + subGroups: [ + { + id: GROUP_ID_10, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + attributes: [ + { + name: 'ATTRIBUTE_10_DROPDOWN', + uiType: Configurator.UiType.DROPDOWN, + required: true, + incomplete: false, + hasConflicts: true, + }, + ], + subGroups: [], + }, + ], + }, + ], + flatGroups: [ + { + id: GROUP_ID_CONFLICT_1, + groupType: Configurator.GroupType.CONFLICT_GROUP, + subGroups: [], + attributes: [], + }, + { + id: GROUP_ID_CONFLICT_2, + groupType: Configurator.GroupType.CONFLICT_GROUP, + subGroups: [], + attributes: [], + }, + { + id: GROUP_ID_CONFLICT_3, + groupType: Configurator.GroupType.CONFLICT_GROUP, + subGroups: [], + attributes: [], + }, + { + id: GROUP_ID_1, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + subGroups: [], + attributes: [ + { + name: 'ATTRIBUTE_1_CHECKBOX', + uiType: Configurator.UiType.CHECKBOXLIST, + required: true, + incomplete: true, + }, + ], + }, + { + id: GROUP_ID_2, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + subGroups: [], + attributes: [ + { + name: 'ATTRIBUTE_2_RADIOBUTTON', + uiType: Configurator.UiType.RADIOBUTTON, + required: false, + incomplete: false, + }, + ], + }, + { + id: GROUP_ID_4, + subGroups: [], + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + attributes: [ + { + name: 'ATTRIBUTE_5_STRING', + uiType: Configurator.UiType.STRING, + required: true, + incomplete: false, + }, + { + name: 'ATTRIBUTE_5_DROPDOWN', + uiType: Configurator.UiType.DROPDOWN, + required: true, + incomplete: true, + }, + ], + }, + { + id: GROUP_ID_6, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + subGroups: [], + attributes: [ + { + name: 'ATTRIBUTE_3_SINGLESELECTIONIMAGE', + uiType: Configurator.UiType.SINGLE_SELECTION_IMAGE, + required: true, + incomplete: true, + }, + ], + }, + { + id: GROUP_ID_7, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + subGroups: [], + attributes: [], + }, + { + id: GROUP_ID_8, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + subGroups: [], + attributes: [], + }, + { + id: GROUP_ID_10, + groupType: Configurator.GroupType.ATTRIBUTE_GROUP, + subGroups: [], + attributes: [ + { + name: 'ATTRIBUTE_10_DROPDOWN', + uiType: Configurator.UiType.DROPDOWN, + required: true, + incomplete: false, + hasConflicts: true, + }, + ], + }, + ], + owner: { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.PRODUCT, + }, + interactionState: { + currentGroup: GROUP_ID_2, + menuParentGroup: GROUP_ID_3, + groupsVisited: {}, + }, +}; diff --git a/feature-libs/product-configurator/rulebased/shared/testing/index.ts b/feature-libs/product-configurator/rulebased/shared/testing/index.ts new file mode 100644 index 00000000000..264b59af831 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/shared/testing/index.ts @@ -0,0 +1 @@ +export * from './configurator-component-test-utils.service'; diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-add-to-cart-button.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-add-to-cart-button.scss new file mode 100644 index 00000000000..8c76e84de06 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-add-to-cart-button.scss @@ -0,0 +1,11 @@ +%cx-configurator-add-to-cart-button { + .cx-add-to-cart-btn-container { + max-width: 1140px; + + @include cx-configurator-footer-container(); + + button.cx-add-to-cart-btn { + @include cx-configurator-footer-container-item(); + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-checkbox-list.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-checkbox-list.scss new file mode 100644 index 00000000000..f80923d732c --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-checkbox-list.scss @@ -0,0 +1,3 @@ +%cx-configurator-attribute-checkbox-list { + @include cx-configurator-attribute-type(); +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-checkbox.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-checkbox.scss new file mode 100644 index 00000000000..1aa0608c878 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-checkbox.scss @@ -0,0 +1,3 @@ +%cx-configurator-attribute-checkbox { + @include cx-configurator-attribute-type(); +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-drop-down.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-drop-down.scss new file mode 100644 index 00000000000..2380223cac9 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-drop-down.scss @@ -0,0 +1,4 @@ +%cx-configurator-attribute-drop-down { + @include cx-configurator-attribute-type(); + @include cx-configurator-form-group(); +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-footer.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-footer.scss new file mode 100644 index 00000000000..22306f95e49 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-footer.scss @@ -0,0 +1,8 @@ +%cx-configurator-attribute-footer { + display: flex; + flex-direction: row; + margin-inline-start: 17px; + margin-inline-end: 17px; + + @include cx-configurator-required-error-msg(); +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-header.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-header.scss new file mode 100644 index 00000000000..9cfc906e9e9 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-header.scss @@ -0,0 +1,40 @@ +%cx-configurator-attribute-header { + display: flex; + flex-direction: column; + margin-inline-start: 17px; + margin-inline-end: 17px; + + label { + @include type('5'); + padding-block-start: 10px; + } + + .cx-required-icon { + &:after { + content: '*'; + color: var(--cx-color-danger); + } + } + + @include cx-configurator-required-error-msg(); + + .cx-conflict-msg { + display: inline-flex; + + cx-icon { + color: var(--cx-color-warning); + font-size: 20px; + padding-inline-start: 5px; + padding-inline-end: 5px; + } + + .cx-conflict-msg { + color: var(--cx-color-text); + font-size: 14px; + } + } + + img { + width: 25%; + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-input-field.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-input-field.scss new file mode 100644 index 00000000000..3668e3cb722 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-input-field.scss @@ -0,0 +1,4 @@ +%cx-configurator-attribute-input-field { + @include cx-configurator-attribute-type(); + @include cx-configurator-form-group(); +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-multi-selection-image.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-multi-selection-image.scss new file mode 100644 index 00000000000..822f3c59423 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-multi-selection-image.scss @@ -0,0 +1,3 @@ +%cx-configurator-attribute-multi-selection-image { + @include cx-configurator-attribute-selection-image(); +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-numeric-input-field.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-numeric-input-field.scss new file mode 100644 index 00000000000..e35bbfc2ae9 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-numeric-input-field.scss @@ -0,0 +1,4 @@ +%cx-configurator-attribute-numeric-input-field { + @include cx-configurator-attribute-type(); + @include cx-configurator-form-group(); +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-radio-button.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-radio-button.scss new file mode 100644 index 00000000000..547d3119d03 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-radio-button.scss @@ -0,0 +1,9 @@ +%cx-configurator-attribute-radio-button { + @include cx-configurator-attribute-type(); + + .form-check { + &:last-child { + margin-block-end: 9px; + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-read-only.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-read-only.scss new file mode 100644 index 00000000000..0daaa7feea7 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-read-only.scss @@ -0,0 +1,8 @@ +%cx-configurator-attribute-read-only { + @include cx-configurator-attribute-type(); + + .cx-read-only-label { + padding-inline-start: 20px; + padding-inline-end: 20px; + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-single-selection-image.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-single-selection-image.scss new file mode 100644 index 00000000000..308bb811204 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-attribute-single-selection-image.scss @@ -0,0 +1,3 @@ +%cx-configurator-attribute-single-selection-image { + @include cx-configurator-attribute-selection-image(); +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-conflict-description.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-conflict-description.scss new file mode 100644 index 00000000000..880d9d348d7 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-conflict-description.scss @@ -0,0 +1,20 @@ +%cx-configurator-conflict-description { + display: flex; + flex-direction: row; + align-items: center; + padding-inline-start: 5px; + padding-inline-end: 5px; + padding-block-start: 5px; + padding-block-end: 5px; + background-color: mix(#ffffff, theme-color('warning'), 78%); + + cx-icon { + color: theme-color('warning'); + align-self: center; + font-size: 30px; + padding-inline-start: 15px; + padding-inline-end: 15px; + padding-block-start: 5px; + padding-block-end: 15px; + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-conflict-suggestion.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-conflict-suggestion.scss new file mode 100644 index 00000000000..c7d19e7e13a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-conflict-suggestion.scss @@ -0,0 +1,30 @@ +%cx-configurator-conflict-suggestion { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + background-color: mix(#ffffff, theme-color('light'), 90%); + border: 1px solid var(--cx-color-light); + border-radius: 2px; + padding-inline-start: 15px; + padding-inline-end: 15px; + padding-block-start: 15px; + padding-block-end: 15px; + margin-inline-start: -20px; + margin-inline-end: -20px; + margin-block-start: 0px; + margin-block-end: 15px; + + @include media-breakpoint-down(sm) { + padding-inline-start: 35px; + padding-inline-end: 35px; + padding-block-start: 15px; + padding-block-end: 15px; + } + + .cx-title { + font-weight: bold; + padding-inline-start: 5px; + padding-inline-end: 5px; + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-form.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-form.scss new file mode 100644 index 00000000000..322fa4d7d70 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-form.scss @@ -0,0 +1,19 @@ +%cx-configurator-form { + .cx-group-attribute { + padding-inline-start: 20px; + padding-inline-end: 20px; + padding-block-start: 12px; + padding-block-end: 12px; + + @include media-breakpoint-down(sm) { + padding-inline-start: 0px; + padding-inline-end: 0px; + padding-block-start: 12px; + padding-block-end: 12px; + } + + em { + @include cx-configurator-attribute-type(); + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-group-menu.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-group-menu.scss new file mode 100644 index 00000000000..4933940cc5e --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-group-menu.scss @@ -0,0 +1,168 @@ +%cx-configurator-group-menu { + ul { + list-style-type: none; + background-color: #ffffff; + border-width: 1px; + border-style: solid; + border-color: var(--cx-color-light); + border-radius: 2px; + padding-inline-start: 0px; + padding-inline-end: 0px; + padding-block-start: 0px; + padding-block-end: 0px; + margin-inline-start: 0px; + margin-inline-end: 25px; + margin-block-start: 0px; + margin-block-end: 0px; + + @include media-breakpoint-down(md) { + background-color: var(--cx-color-background); + margin-inline-start: 0px; + margin-inline-end: 0px; + margin-block-start: 0px; + margin-block-end: 0px; + } + + .cx-menu-item { + line-height: var(--cx-line-height, 1.6); + border-width: 0 0 1px 0; + border-style: solid; + border-color: var(--cx-color-light); + + &:last-child { + border-width: 0; + } + + a.cx-menu-item-link { + display: flex; + flex-direction: row; + justify-content: space-between; + align-content: center; + align-items: center; + text-decoration: none; + color: var(--cx-color-text); + + padding-inline-start: 15px; + padding-inline-end: 15px; + padding-block-start: 15px; + padding-block-end: 15px; + + &:hover { + cursor: pointer; + color: var(--cx-color-primary); + } + + &.active { + color: var(--cx-color-primary); + } + + &.disable { + cursor: not-allowed; + pointer-events: none; + } + + span { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + line-break: anywhere; + } + + .groupIndicators { + display: flex; + flex-direction: row; + align-self: flex-start; + inline-size: 100px; + + .groupStatusIndicator { + inline-size: 25px; + display: flex; + flex-direction: row; + justify-content: flex-start; + cx-icon.ERROR, + cx-icon.WARNING, + cx-icon.COMPLETE { + display: none; + } + + cx-icon.ERROR { + color: var(--cx-color-danger); + padding-inline-end: 5px; + } + cx-icon.WARNING { + color: var(--cx-color-warning); + padding-inline-end: 5px; + } + + cx-icon.COMPLETE { + color: var(--cx-color-text); + padding-inline-end: 5px; + } + } + + .subGroupIndicator { + inline-size: 25px; + cx-icon { + margin-inline-start: 5px; + margin-inline-end: 5px; + } + } + + .conflictNumberIndicator { + inline-size: 25px; + } + } + } + + &.ERROR cx-icon.ERROR, + &.WARNING cx-icon.WARNING, + &.COMPLETE cx-icon.COMPLETE { + display: inline-block !important; + } + + &.cx-menu-conflict { + background-color: mix(#ffffff, theme-color('warning'), 78%); + } + } + } + + .cx-menu-back { + line-height: var(--cx-line-height, 1.6); + border-width: 0 0 1px 0; + border-style: solid; + border-color: var(--cx-color-light); + background-color: var(--cx-color-background); + + a.cx-menu-back-link { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-content: center; + align-items: center; + text-decoration: none; + color: var(--cx-color-text); + + padding-inline-start: 15px; + padding-inline-end: 15px; + padding-block-start: 15px; + padding-block-end: 15px; + + &:hover { + cursor: pointer; + color: var(--cx-color-primary); + } + + &.active { + color: var(--cx-color-primary); + } + + cx-icon { + margin-inline-start: 5px; + margin-inline-end: 5px; + margin-block-start: 5px; + margin-block-end: 5px; + } + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-group-title.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-group-title.scss new file mode 100644 index 00000000000..b2e8ed49fd8 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-group-title.scss @@ -0,0 +1,23 @@ +%cx-configurator-group-title { + display: none; + + &:not(:empty) { + display: flex; + flex-direction: row; + align-items: center; + font-weight: bold; + background-color: var(--cx-color-background); + border: 1px solid var(--cx-color-light); + border-radius: 2px; + height: fit-content; + padding-inline-start: 15px; + padding-inline-end: 15px; + padding-block-start: 15px; + padding-block-end: 15px; + + margin-inline-start: 0px; + margin-inline-end: 0px; + margin-block-start: 0px; + margin-block-end: 15px; + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-overview-attribute.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-overview-attribute.scss new file mode 100644 index 00000000000..cdb33d77c75 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-overview-attribute.scss @@ -0,0 +1,23 @@ +%cx-configurator-overview-attribute { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + + .cx-attribute-value { + width: 100%; + + @include media-breakpoint-up(md) { + width: 41.6666666667%; + } + } + + .cx-attribute-label { + color: var(--cx-color-secondary); + width: 100%; + + @include media-breakpoint-up(md) { + width: 58.3333333333%; + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-overview-form.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-overview-form.scss new file mode 100644 index 00000000000..4f48a9dc462 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-overview-form.scss @@ -0,0 +1,45 @@ +%cx-configurator-overview-form { + display: flex; + flex-direction: column; + justify-content: flex-start; + max-width: 1140px; + padding-inline-start: 20px; + padding-inline-end: 25px; + padding-block-start: 5px; + padding-block-end: 5px; + + @include media-breakpoint-down(sm) { + padding-inline-start: 20px; + padding-inline-end: 20px; + } + + .cx-group { + padding-inline-start: 0px; + padding-inline-end: 0px; + padding-block-start: 25px; + padding-block-end: 25px; + + margin-inline-start: -20px; + margin-inline-end: -25px; + + h2 { + padding-inline-start: 30px; + padding-inline-end: 30px; + padding-block-start: 15px; + padding-block-end: 15px; + border: solid 1px var(--cx-color-light); + } + + .cx-attribute-value-pair { + padding-inline-start: 30px; + padding-inline-end: 30px; + } + } + + .cx-no-attribute-value-pairs { + padding-inline-start: 10px; + padding-inline-end: 10px; + padding-block-start: 25px; + padding-block-end: 25px; + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-overview-notification-banner.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-overview-notification-banner.scss new file mode 100644 index 00000000000..ad39dfee585 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-overview-notification-banner.scss @@ -0,0 +1,54 @@ +%cx-configurator-overview-notification-banner { + display: none; + + &:not(:empty) { + display: flex; + flex-direction: row; + justify-content: flex-start; + max-width: 1140px; + background-color: rgba(245, 206, 206, 1); + margin-block-end: 1.25rem; + padding-inline-start: 20px; + padding-inline-end: 25px; + padding-block-start: 5px; + padding-block-end: 5px; + + @include media-breakpoint-up(xs) { + align-items: center; + } + + @include media-breakpoint-down(sm) { + padding-inline-start: 20px; + padding-inline-end: 20px; + } + + cx-icon, + .cx-icon { + align-self: flex-start; + color: var(--cx-color-danger); + font-size: 30px; + padding-inline-start: 5px; + padding-inline-end: 15px; + padding-block-start: 5px; + padding-block-end: 5px; + } + + .cx-error-msg { + padding-inline-end: 15px; + + button.link { + color: var(--cx-color-text); + border-width: 0; + background-color: transparent; + font-size: inherit; + inline-size: max-content; + text-decoration: underline; + + &:hover { + color: var(--cx-color-primary); + text-decoration: none; + } + } + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-previous-next-buttons.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-previous-next-buttons.scss new file mode 100644 index 00000000000..f43298397c0 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-previous-next-buttons.scss @@ -0,0 +1,40 @@ +%cx-configurator-previous-next-buttons { + display: none; + + &:not(:empty) { + display: flex; + flex-direction: row; + justify-content: space-between; + padding-inline-start: 15px; + padding-inline-end: 15px; + padding-block-start: 16px; + padding-block-end: 16px; + + @include media-breakpoint-down(sm) { + padding-inline-start: 20px; + padding-inline-end: 20px; + padding-block-start: 20px; + padding-block-end: 20px; + } + + .btn-action, + .btn-secondary { + inline-size: 25%; + + @include media-breakpoint-down(sm) { + inline-size: 45%; + } + } + + .btn-action { + &.disabled, + &:disabled { + &:hover { + @extend .btn-outline-text !optional; + border-width: 2px; + border-style: solid; + } + } + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-price-summary.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-price-summary.scss new file mode 100644 index 00000000000..a701570e3f4 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-price-summary.scss @@ -0,0 +1,37 @@ +%cx-configurator-price-summary { + .cx-price-summary-container { + max-width: 1140px; + + @include cx-configurator-footer-container(); + + .cx-total-summary { + @include cx-configurator-footer-container-item(); + + .cx-summary-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding-block-start: 10px; + padding-block-end: 10px; + + .cx-label { + flex-grow: 1; + } + + .cx-amount { + word-wrap: break-word; + text-align: end; + flex-grow: 1; + } + } + + .cx-total-price { + border-block-start: 1px solid var(--cx-color-light); + + .cx-label { + font-weight: bold; + } + } + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-product-title.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-product-title.scss new file mode 100644 index 00000000000..6515ccf23d3 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-product-title.scss @@ -0,0 +1,108 @@ +%cx-configurator-product-title { + display: none; + + &:not(:empty) { + display: flex; + flex-direction: row; + justify-content: center; + background-color: var(--cx-color-background); + margin-block-end: 15px; + padding-inline-start: 0px; + padding-inline-end: 0px; + padding-block-start: 15px; + padding-block-end: 15px; + + .cx-general-product-info { + display: flex; + flex-direction: column; + + @include media-breakpoint-up(lg) { + width: 1140px; + } + + @include media-breakpoint-down(lg) { + width: 100%; + } + + .cx-title { + font-weight: bold; + align-self: center; + min-block-size: 1rem; + } + + a { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-self: flex-end; + cursor: pointer; + margin-block-start: -25px; + margin-inline-end: 5px; + font-weight: normal; + color: var(--cx-color-text); + + &:hover { + text-decoration: none; + } + + cx-icon { + margin-inline-start: 5px; + margin-inline-end: 5px; + align-self: center; + } + + @include media-breakpoint-down(xs) { + margin-block-start: -20px; + } + + .cx-toggle-details-link-text { + text-decoration: underline; + @include media-breakpoint-down(xs) { + display: none; + } + } + } + + .cx-details { + inline-size: 100%; + max-block-size: 0; + overflow: hidden; + display: flex; + font-size: var(--cx-font-small, 0.8rem); + + transition: all 0.7s ease-in-out; + -webkit-transition: all 0.7s ease-in-out; + + &.open { + // A larger value than the details need, this will be used to animate the closing/opening. + // If the value is to large the closing will "delay" as the max-height will be reduced from the specified value down to 0. + max-block-size: 400px; + } + + .cx-details-image { + margin-block-start: 15px; + margin-block-end: 0px; + max-inline-size: 100px; + } + + .cx-details-content { + display: flex; + flex-direction: column; + margin-inline-start: 15px; + margin-inline-end: 0px; + margin-block-start: 15px; + margin-block-end: 0px; + + .cx-detail-title, + .cx-price { + font-weight: bold; + } + + .cx-code { + color: var(--cx-color-secondary); + } + } + } + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-tab-bar.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-tab-bar.scss new file mode 100644 index 00000000000..374e5674d81 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-tab-bar.scss @@ -0,0 +1,80 @@ +%cx-configurator-tab-bar { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + padding-inline-start: 0px; + padding-inline-end: 0px; + + @include media-breakpoint-up(lg) { + border-width: 0 0 1px 0; + border-style: solid; + border-color: var(--cx-color-light); + margin-block-end: 2rem; + } + + @include media-breakpoint-up(xl) { + max-width: 1140px; + } + + @include media-breakpoint-down(xl) { + width: 100%; + } + + a { + color: black; + cursor: pointer; + padding: 0.5rem 0 0 0; + text-align: center; + + @include media-breakpoint-up(md) { + flex-basis: 200px; + } + + @include media-breakpoint-down(sm) { + inline-size: 50%; + } + + @include media-breakpoint-down(sm) { + inline-size: 50%; + } + + // border effect + &:after { + content: ''; + display: block; + block-size: 5px; + background: var(--cx-color-primary); + margin-inline-start: auto; + margin-inline-end: auto; + margin-block-start: 7px; + margin-block-end: auto; + + // the tab hover effect uses a border in the :after pseudo + // that is animated from 0% to 100% width + inline-size: 0; + opacity: 0; + transition: all 0.6s; + } + + &.active, + &:hover { + color: var(--cx-color-primary); + text-decoration: none; + } + + &.active:after, + &:hover:after { + inline-size: 100%; + } + + &.active:after, + &.active:hover:after { + opacity: 1; + } + + &:not(.active):hover:after { + opacity: 0.5; + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_configurator-update-message.scss b/feature-libs/product-configurator/rulebased/styles/_configurator-update-message.scss new file mode 100644 index 00000000000..87d12173a2a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_configurator-update-message.scss @@ -0,0 +1,48 @@ +%cx-configurator-update-message { + position: absolute; + width: 100%; + z-index: 99; + + div.cx-update-msg { + display: none; + flex-direction: row; + align-items: center; + justify-content: center; + width: 100%; + min-height: fit-content; + top: 0; + @include type('6', 'semi'); + padding-inline-start: 10px; + padding-inline-end: 10px; + padding-block-start: 10px; + padding-block-end: 10px; + background-color: mix(#ffffff, theme-color('info'), 80%); + position: sticky; + + &.visible { + display: flex; + + cx-spinner { + margin-inline-start: 10px; + margin-inline-end: 10px; + + .loader-container { + block-size: 40px; + inline-size: 40px; + margin: 0 auto; + + .loader { + block-size: 40px; + inline-size: 40px; + + &::before { + block-size: 40px; + inline-size: 40px; + border-width: 5px; + } + } + } + } + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/_index.scss b/feature-libs/product-configurator/rulebased/styles/_index.scss new file mode 100644 index 00000000000..f7d49f6ee9a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/_index.scss @@ -0,0 +1,27 @@ +@import './mixins/mixins'; +@import 'configurator-attribute-radio-button'; +@import 'configurator-attribute-drop-down'; +@import 'configurator-attribute-input-field'; +@import 'configurator-attribute-numeric-input-field'; +@import 'configurator-attribute-read-only'; +@import 'configurator-attribute-checkbox'; +@import 'configurator-attribute-checkbox-list'; +@import 'configurator-attribute-header'; +@import 'configurator-attribute-footer'; +@import 'configurator-form'; +@import 'configurator-overview-form'; +@import 'configurator-overview-attribute'; +@import 'configurator-previous-next-buttons'; +@import 'configurator-group-menu'; +@import 'configurator-group-title'; +@import 'configurator-product-title'; +@import 'configurator-add-to-cart-button'; +@import 'configurator-price-summary'; +@import 'configurator-tab-bar'; +@import 'configurator-attribute-multi-selection-image'; +@import 'configurator-attribute-single-selection-image'; +@import 'configurator-update-message'; +@import 'configurator-conflict-suggestion'; +@import 'configurator-conflict-description'; +@import 'configurator-overview-notification-banner'; +@import './pages/index'; diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-attribute-selection-image.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-attribute-selection-image.scss new file mode 100644 index 00000000000..ec88c80b966 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-attribute-selection-image.scss @@ -0,0 +1,70 @@ +@mixin cx-configurator-attribute-selection-image { + @include cx-configurator-attribute-type(); + + .cx-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + align-items: baseline; + + .cx-configurator-select { + padding-inline-start: 5px; + padding-inline-end: 5px; + padding-block-start: 5px; + padding-block-end: 5px; + + input[type='checkbox'].form-input, + input[type='radio'].form-input { + opacity: 0; + position: absolute; + + &:focus + .cx-label-container { + @include visible-focus(); + } + } + + input[aria-checked='true'] + .cx-label-container { + .cx-img, + .cx-img-dummy { + border: var(--cx-color-primary) 3px solid; + } + } + + .cx-label-container { + padding-inline-start: 25px; + padding-inline-end: 25px; + padding-block-start: 5px; + padding-block-end: 5px; + + margin-inline-start: 10px; + margin-inline-end: 10px; + margin-block-start: 5px; + margin-block-end: 5px; + + label { + cursor: pointer; + text-align: center; + + .cx-img, + .cx-img-dummy { + display: block; + border-radius: 2px; + border: transparent 3px solid; + padding-inline-start: 3px; + padding-inline-end: 3px; + padding-block-start: 3px; + padding-block-end: 3px; + + margin-inline-start: auto; + margin-inline-end: auto; + } + + .cx-img-dummy { + block-size: 75%; + } + } + } + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-attribute-type.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-attribute-type.scss new file mode 100644 index 00000000000..2d5b7496fd9 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-attribute-type.scss @@ -0,0 +1,10 @@ +@mixin cx-configurator-attribute-type { + display: flex; + flex-direction: row; + padding-inline-start: 0px; + padding-inline-end: 34px; + padding-block-start: 10px; + padding-block-end: 0px; + + margin-inline-start: 17px; +} diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-footer-container-item.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-footer-container-item.scss new file mode 100644 index 00000000000..4c454c5baf6 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-footer-container-item.scss @@ -0,0 +1,9 @@ +@mixin cx-configurator-footer-container-item { + @include media-breakpoint-down(sm) { + inline-size: 100%; + } + + padding-inline-start: 10px; + padding-inline-end: 10px; + inline-size: 40%; +} diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-footer-container.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-footer-container.scss new file mode 100644 index 00000000000..59567669502 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-footer-container.scss @@ -0,0 +1,18 @@ +@mixin cx-configurator-footer-container { + display: flex; + justify-content: flex-end; + margin: 0 auto; + padding-inline-start: 15px; + padding-inline-end: 15px; + padding-block-start: 15px; + padding-block-end: 15px; + margin-block-start: 15px; + + @include media-breakpoint-down(sm) { + padding-inline-start: 20px; + padding-inline-end: 20px; + padding-block-start: 20px; + padding-block-end: 20px; + justify-content: center; + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-form-group.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-form-group.scss new file mode 100644 index 00000000000..55169138ee9 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-form-group.scss @@ -0,0 +1,13 @@ +@mixin cx-configurator-form-group { + .form-group { + margin-block-start: 0.5rem; + margin-block-end: 0.5rem; + + @include media-breakpoint-up(md) { + inline-size: 75%; + } + @include media-breakpoint-down(sm) { + inline-size: 100%; + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-required-error-msg.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-required-error-msg.scss new file mode 100644 index 00000000000..8ba03c53b1f --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-required-error-msg.scss @@ -0,0 +1,6 @@ +@mixin cx-configurator-required-error-msg { + .cx-required-error-msg { + color: var(--cx-color-danger); + font-size: 14px; + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-template.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-template.scss new file mode 100644 index 00000000000..31547d019f6 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/mixins/_configurator-template.scss @@ -0,0 +1,9 @@ +@mixin cx-configurator-template { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + align-content: flex-start; + padding-block-end: 2rem; + margin: auto; +} diff --git a/feature-libs/product-configurator/rulebased/styles/mixins/_mixins.scss b/feature-libs/product-configurator/rulebased/styles/mixins/_mixins.scss new file mode 100644 index 00000000000..e4b0008232a --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/mixins/_mixins.scss @@ -0,0 +1,7 @@ +@import 'configurator-template'; +@import 'configurator-attribute-type'; +@import 'configurator-form-group'; +@import 'configurator-required-error-msg'; +@import 'configurator-attribute-selection-image'; +@import 'configurator-footer-container'; +@import 'configurator-footer-container-item'; diff --git a/feature-libs/product-configurator/rulebased/styles/pages/_configurator-variant-overview-page.scss b/feature-libs/product-configurator/rulebased/styles/pages/_configurator-variant-overview-page.scss new file mode 100644 index 00000000000..db3f63c8a91 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/pages/_configurator-variant-overview-page.scss @@ -0,0 +1,3 @@ +%VariantConfigurationOverviewTemplate { + @include cx-configurator-template(); +} diff --git a/feature-libs/product-configurator/rulebased/styles/pages/_configurator-variant-page.scss b/feature-libs/product-configurator/rulebased/styles/pages/_configurator-variant-page.scss new file mode 100644 index 00000000000..ea7801890da --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/pages/_configurator-variant-page.scss @@ -0,0 +1,28 @@ +%VariantConfigurationTemplate { + @include cx-configurator-template(); + + cx-page-slot.VariantConfigContent, + cx-page-slot.VariantConfigMenu { + height: fit-content; + } + + @include media-breakpoint-up(lg) { + cx-page-slot.VariantConfigMenu { + max-inline-size: 30%; + } + + cx-page-slot.VariantConfigContent { + max-inline-size: 70%; + } + } + + @include media-breakpoint-up(xl) { + cx-page-slot.VariantConfigMenu { + max-inline-size: calc(1140px * 0.3); + } + + cx-page-slot.VariantConfigContent { + max-inline-size: calc(1140px * 0.7); + } + } +} diff --git a/feature-libs/product-configurator/rulebased/styles/pages/_index.scss b/feature-libs/product-configurator/rulebased/styles/pages/_index.scss new file mode 100644 index 00000000000..b4b17ce0a68 --- /dev/null +++ b/feature-libs/product-configurator/rulebased/styles/pages/_index.scss @@ -0,0 +1,2 @@ +@import 'configurator-variant-overview-page'; +@import 'configurator-variant-page'; diff --git a/feature-libs/product-configurator/schematics/.gitignore b/feature-libs/product-configurator/schematics/.gitignore new file mode 100644 index 00000000000..c88f4d69e15 --- /dev/null +++ b/feature-libs/product-configurator/schematics/.gitignore @@ -0,0 +1,18 @@ +# Outputs +**/*.js +**/*.js.map +**/*.d.ts + +# IDEs +.idea/ +jsconfig.json +.vscode/ + +# Misc +node_modules/ +npm-debug.log* +yarn-error.log* + +# Mac OSX Finder files. +**/.DS_Store +.DS_Store diff --git a/feature-libs/product-configurator/schematics/add-product-configurator/index.ts b/feature-libs/product-configurator/schematics/add-product-configurator/index.ts new file mode 100644 index 00000000000..379e52922a7 --- /dev/null +++ b/feature-libs/product-configurator/schematics/add-product-configurator/index.ts @@ -0,0 +1,127 @@ +import { + chain, + Rule, + SchematicContext, + Tree, +} from '@angular-devkit/schematics'; +import { + NodeDependency, + NodeDependencyType, +} from '@schematics/angular/utility/dependencies'; +import { + addLibraryFeature, + addPackageJsonDependencies, + getAppModule, + getSpartacusSchematicsVersion, + installPackageJsonDependencies, + LibraryOptions as SpartacusProductConfiguratorOptions, + readPackageJson, + validateSpartacusInstallation, +} from '@spartacus/schematics'; + +export const CLI_PRODUCT_CONFIGURATOR_FEATURE = 'ProductConfigurator'; +export const SPARTACUS_PRODUCT_CONFIGURATOR = '@spartacus/product-configurator'; + +const PRODUCT_CONFIGURATOR_SCSS_FILE_NAME = 'product-configurator.scss'; +const PRODUCT_CONFIGURATOR_RULEBASED_MODULE = 'RulebasedConfiguratorModule'; +const PRODUCT_CONFIGURATOR_TEXTFIELD_MODULE = 'TextfieldConfiguratorModule'; +const PRODUCT_CONFIGURATOR_RULEBASED_FEATURE_NAME = 'rulebased'; +const PRODUCT_CONFIGURATOR_TEXTFIELD_FEATURE_NAME = 'textfield'; + +const PRODUCT_CONFIGURATOR_RULEBASED_ROOT_MODULE = + 'RulebasedConfiguratorRootModule'; +const PRODUCT_CONFIGURATOR_TEXTFIELD_ROOT_MODULE = + 'TextfieldConfiguratorRootModule'; +const SPARTACUS_PRODUCT_CONFIGURATOR_RULEBASED = + '@spartacus/product-configurator/rulebased'; +const SPARTACUS_PRODUCT_CONFIGURATOR_TEXTFIELD = + '@spartacus/product-configurator/textfield'; + +const SPARTACUS_PRODUCT_CONFIGURATOR_RULEBASED_ROOT = `${SPARTACUS_PRODUCT_CONFIGURATOR_RULEBASED}/root`; +const SPARTACUS_PRODUCT_CONFIGURATOR_TEXTFIELD_ROOT = `${SPARTACUS_PRODUCT_CONFIGURATOR_TEXTFIELD}/root`; +const SPARTACUS_PRODUCT_CONFIGURATOR_ASSETS = `${SPARTACUS_PRODUCT_CONFIGURATOR}/common/assets`; +const PRODUCT_CONFIGURATOR_TRANSLATIONS = 'configuratorTranslations'; +const PRODUCT_CONFIGURATOR_TRANSLATION_CHUNKS_CONFIG = + 'configuratorTranslationChunksConfig'; + +export function addProductConfiguratorFeatures( + options: SpartacusProductConfiguratorOptions +): Rule { + return (tree: Tree, _context: SchematicContext) => { + const packageJson = readPackageJson(tree); + validateSpartacusInstallation(packageJson); + + const appModulePath = getAppModule(tree, options.project); + + return chain([ + addProductConfiguratorRulebasedFeature(appModulePath, options), + addProductConfiguratorTextfieldFeature(appModulePath, options), + addProductConfiguratorPackageJsonDependencies(packageJson), + installPackageJsonDependencies(), + ]); + }; +} + +function addProductConfiguratorRulebasedFeature( + appModulePath: string, + options: SpartacusProductConfiguratorOptions +): Rule { + return addLibraryFeature(appModulePath, options, { + name: PRODUCT_CONFIGURATOR_RULEBASED_FEATURE_NAME, + featureModule: { + name: PRODUCT_CONFIGURATOR_RULEBASED_MODULE, + importPath: SPARTACUS_PRODUCT_CONFIGURATOR_RULEBASED, + }, + rootModule: { + name: PRODUCT_CONFIGURATOR_RULEBASED_ROOT_MODULE, + importPath: SPARTACUS_PRODUCT_CONFIGURATOR_RULEBASED_ROOT, + }, + i18n: { + resources: PRODUCT_CONFIGURATOR_TRANSLATIONS, + chunks: PRODUCT_CONFIGURATOR_TRANSLATION_CHUNKS_CONFIG, + importPath: SPARTACUS_PRODUCT_CONFIGURATOR_ASSETS, + }, + styles: { + scssFileName: PRODUCT_CONFIGURATOR_SCSS_FILE_NAME, + importStyle: SPARTACUS_PRODUCT_CONFIGURATOR, + }, + }); +} + +function addProductConfiguratorTextfieldFeature( + appModulePath: string, + options: SpartacusProductConfiguratorOptions +): Rule { + return addLibraryFeature(appModulePath, options, { + name: PRODUCT_CONFIGURATOR_TEXTFIELD_FEATURE_NAME, + featureModule: { + name: PRODUCT_CONFIGURATOR_TEXTFIELD_MODULE, + importPath: SPARTACUS_PRODUCT_CONFIGURATOR_TEXTFIELD, + }, + rootModule: { + name: PRODUCT_CONFIGURATOR_TEXTFIELD_ROOT_MODULE, + importPath: SPARTACUS_PRODUCT_CONFIGURATOR_TEXTFIELD_ROOT, + }, + i18n: { + resources: PRODUCT_CONFIGURATOR_TRANSLATIONS, + chunks: PRODUCT_CONFIGURATOR_TRANSLATION_CHUNKS_CONFIG, + importPath: SPARTACUS_PRODUCT_CONFIGURATOR_ASSETS, + }, + styles: { + scssFileName: PRODUCT_CONFIGURATOR_SCSS_FILE_NAME, + importStyle: SPARTACUS_PRODUCT_CONFIGURATOR, + }, + }); +} + +function addProductConfiguratorPackageJsonDependencies(packageJson: any): Rule { + const spartacusVersion = `^${getSpartacusSchematicsVersion()}`; + const dependencies: NodeDependency[] = [ + { + type: NodeDependencyType.Default, + version: spartacusVersion, + name: SPARTACUS_PRODUCT_CONFIGURATOR, + }, + ]; + return addPackageJsonDependencies(dependencies, packageJson); +} diff --git a/feature-libs/product-configurator/schematics/add-product-configurator/index_spec.ts b/feature-libs/product-configurator/schematics/add-product-configurator/index_spec.ts new file mode 100644 index 00000000000..fa49224a4aa --- /dev/null +++ b/feature-libs/product-configurator/schematics/add-product-configurator/index_spec.ts @@ -0,0 +1,254 @@ +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import { + LibraryOptions as SpartacusProductConfiguratorOptions, + SpartacusOptions, +} from '@spartacus/schematics'; +import * as path from 'path'; +import { + CLI_PRODUCT_CONFIGURATOR_FEATURE, + SPARTACUS_PRODUCT_CONFIGURATOR, +} from './index'; + +const collectionPath = path.join(__dirname, '../collection.json'); +const appModulePath = 'src/app/app.module.ts'; + +describe('Spartacus product configurator schematics: ng-add', () => { + const schematicRunner = new SchematicTestRunner('schematics', collectionPath); + + let appTree: UnitTestTree; + + const workspaceOptions: any = { + name: 'workspace', + version: '0.5.0', + }; + + const appOptions: any = { + name: 'schematics-test', + inlineStyle: false, + inlineTemplate: false, + routing: false, + style: 'scss', + skipTests: false, + projectRoot: '', + }; + + const defaultOptions: SpartacusProductConfiguratorOptions = { + project: 'schematics-test', + lazy: true, + features: [CLI_PRODUCT_CONFIGURATOR_FEATURE], + }; + + const spartacusDefaultOptions: SpartacusOptions = { + project: 'schematics-test', + }; + + beforeEach(async () => { + schematicRunner.registerCollection( + '@spartacus/schematics', + '../../projects/schematics/src/collection.json' + ); + schematicRunner.registerCollection( + '@spartacus/organization', + '../../feature-libs/organization/schematics/collection.json' + ); + + appTree = await schematicRunner + .runExternalSchematicAsync( + '@schematics/angular', + 'workspace', + workspaceOptions + ) + .toPromise(); + appTree = await schematicRunner + .runExternalSchematicAsync( + '@schematics/angular', + 'application', + appOptions, + appTree + ) + .toPromise(); + appTree = await schematicRunner + .runExternalSchematicAsync( + '@spartacus/schematics', + 'ng-add', + { ...spartacusDefaultOptions, name: 'schematics-test' }, + appTree + ) + .toPromise(); + }); + + describe('Product config feature', () => { + describe('styling', () => { + beforeEach(async () => { + appTree = await schematicRunner + .runSchematicAsync('ng-add', defaultOptions, appTree) + .toPromise(); + }); + + it('should add style import to /src/styles/spartacus/product-configurator.scss', async () => { + const content = appTree.readContent( + '/src/styles/spartacus/product-configurator.scss' + ); + expect(content).toEqual(`@import "@spartacus/product-configurator";`); + }); + + it('should add update angular.json with spartacus/product-configurator.scss', async () => { + const content = appTree.readContent('/angular.json'); + const angularJson = JSON.parse(content); + const buildStyles: string[] = + angularJson.projects['schematics-test'].architect.build.options + .styles; + expect(buildStyles).toEqual([ + 'src/styles.scss', + 'src/styles/spartacus/product-configurator.scss', + ]); + + const testStyles: string[] = + angularJson.projects['schematics-test'].architect.test.options.styles; + expect(testStyles).toEqual([ + 'src/styles.scss', + 'src/styles/spartacus/product-configurator.scss', + ]); + }); + }); + + describe('eager loading', () => { + beforeEach(async () => { + appTree = await schematicRunner + .runSchematicAsync( + 'ng-add', + { ...defaultOptions, lazy: false }, + appTree + ) + .toPromise(); + }); + + it('should add product-configurator deps', async () => { + const packageJson = appTree.readContent('/package.json'); + const packageObj = JSON.parse(packageJson); + const depPackageList = Object.keys(packageObj.dependencies); + expect(depPackageList.includes(SPARTACUS_PRODUCT_CONFIGURATOR)).toBe( + true + ); + }); + + it('should import rulebased root module', async () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).toContain( + `import { RulebasedConfiguratorRootModule } from '@spartacus/product-configurator/rulebased/root';` + ); + }); + + it('should import textfield root module', async () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).toContain( + `import { TextfieldConfiguratorRootModule } from '@spartacus/product-configurator/textfield/root';` + ); + }); + + it('should not contain lazy loading syntax', async () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).not.toContain( + `import('@spartacus/product-configurator/rulebased').then(` + ); + }); + }); + + describe('lazy loading', () => { + beforeEach(async () => { + appTree = await schematicRunner + .runSchematicAsync('ng-add', defaultOptions, appTree) + .toPromise(); + }); + + it('should add product-configurator deps', async () => { + const packageJson = appTree.readContent('/package.json'); + const packageObj = JSON.parse(packageJson); + const depPackageList = Object.keys(packageObj.dependencies); + expect(depPackageList.includes('@spartacus/product-configurator')).toBe( + true + ); + }); + + it('should import rulebased root module and contain the lazy loading syntax', async () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).toContain( + `import { RulebasedConfiguratorRootModule } from '@spartacus/product-configurator/rulebased/root';` + ); + expect(appModule).toContain( + `import('@spartacus/product-configurator/rulebased').then(` + ); + }); + + it('should import textfield root module and contain the lazy loading syntax', async () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).toContain( + `import { TextfieldConfiguratorRootModule } from '@spartacus/product-configurator/textfield/root';` + ); + expect(appModule).toContain( + `import('@spartacus/product-configurator/textfield').then(` + ); + }); + + it('should not contain the rulebase module import', () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).not.toContain( + `import { RulebasedConfiguratorModule } from '@spartacus/product-configurator/rulebased';` + ); + }); + + it('should not contain the textfield module import', () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).not.toContain( + `import { TextfieldConfiguratorModule } from '@spartacus/product-configurator/textfield';` + ); + }); + }); + describe('i18n', () => { + beforeEach(async () => { + appTree = await schematicRunner + .runSchematicAsync('ng-add', defaultOptions, appTree) + .toPromise(); + }); + + it('should import the i18n resource and chunk from assets', async () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).toContain( + `import { configuratorTranslations } from '@spartacus/product-configurator/common/assets';` + ); + }); + it('should provideConfig', async () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule).toContain(`resources: configuratorTranslations,`); + expect(appModule).toContain( + `chunks: configuratorTranslationChunksConfig,` + ); + }); + }); + }); + + describe('when other Spartacus features are already installed', () => { + beforeEach(async () => { + appTree = await schematicRunner + .runExternalSchematicAsync( + '@spartacus/organization', + 'ng-add', + { ...spartacusDefaultOptions, name: 'schematics-test' }, + appTree + ) + .toPromise(); + appTree = await schematicRunner + .runSchematicAsync('ng-add', defaultOptions, appTree) + .toPromise(); + }); + + it('should just append productconfig feature without duplicating the featureModules config', () => { + const appModule = appTree.readContent(appModulePath); + expect(appModule.match(/featureModules:/g).length).toEqual(1); + expect(appModule).toContain(`rulebased: {`); + }); + }); +}); diff --git a/feature-libs/product-configurator/schematics/add-product-configurator/schema.json b/feature-libs/product-configurator/schematics/add-product-configurator/schema.json new file mode 100644 index 00000000000..ad7c58005cc --- /dev/null +++ b/feature-libs/product-configurator/schematics/add-product-configurator/schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "ProductConfiguratorSchematics", + "title": "Product Configurator Schematics", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + } + }, + "lazy": { + "type": "boolean", + "description": "Lazy load the product configurator features.", + "default": true + } + }, + "required": [] +} diff --git a/feature-libs/product-configurator/schematics/collection.json b/feature-libs/product-configurator/schematics/collection.json new file mode 100644 index 00000000000..df4bfdf5126 --- /dev/null +++ b/feature-libs/product-configurator/schematics/collection.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "factory": "./add-product-configurator/index#addProductConfiguratorFeatures", + "description": "Add and configure Spartacus' product configurator features", + "schema": "./add-product-configurator/schema.json", + "private": true, + "hidden": true, + "aliases": ["install"] + } + } +} diff --git a/feature-libs/product-configurator/test.ts b/feature-libs/product-configurator/test.ts new file mode 100644 index 00000000000..e5b4f28a3b3 --- /dev/null +++ b/feature-libs/product-configurator/test.ts @@ -0,0 +1,35 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import { getTestBed } from '@angular/core/testing'; +import '@angular/localize/init'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; +import 'zone.js/dist/zone'; +import 'zone.js/dist/zone-testing'; + +declare const require: { + context( + path: string, + deep?: boolean, + filter?: RegExp + ): { + keys(): string[]; + (id: string): T; + }; +}; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); +// And load the modules. +context + .keys() + // filter tests from node_modules + .filter((key) => !key.startsWith('@')) + .forEach(context); diff --git a/feature-libs/product-configurator/textfield/_index.scss b/feature-libs/product-configurator/textfield/_index.scss new file mode 100644 index 00000000000..1fc308a0347 --- /dev/null +++ b/feature-libs/product-configurator/textfield/_index.scss @@ -0,0 +1,25 @@ +@import 'styles/index'; + +$configurator-textfield-components: cx-configurator-textfield-input-field, + cx-configurator-textfield-form, cx-configurator-textfield-add-to-cart-button !default; + +$configurator-textfield-pages: TextfieldConfigurationTemplate !default; + +@each $selector in $configurator-textfield-components { + #{$selector} { + @extend %#{$selector} !optional; + } +} + +// add body specific selectors +body { + @each $selector in $configurator-textfield-components { + @extend %#{$selector}__body !optional; + } +} + +@each $selector in $configurator-textfield-pages { + cx-page-layout.#{$selector} { + @extend %#{$selector} !optional; + } +} diff --git a/feature-libs/product-configurator/textfield/components/add-to-cart-button/configurator-textfield-add-to-cart-button.component.html b/feature-libs/product-configurator/textfield/components/add-to-cart-button/configurator-textfield-add-to-cart-button.component.html new file mode 100644 index 00000000000..6bee915e1ac --- /dev/null +++ b/feature-libs/product-configurator/textfield/components/add-to-cart-button/configurator-textfield-add-to-cart-button.component.html @@ -0,0 +1,7 @@ + diff --git a/feature-libs/product-configurator/textfield/components/add-to-cart-button/configurator-textfield-add-to-cart-button.component.spec.ts b/feature-libs/product-configurator/textfield/components/add-to-cart-button/configurator-textfield-add-to-cart-button.component.spec.ts new file mode 100644 index 00000000000..691ba7b5c59 --- /dev/null +++ b/feature-libs/product-configurator/textfield/components/add-to-cart-button/configurator-textfield-add-to-cart-button.component.spec.ts @@ -0,0 +1,157 @@ +import { + ChangeDetectionStrategy, + Pipe, + PipeTransform, + Type, +} from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + I18nTestingModule, + RouterState, + RoutingService, +} from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable, of } from 'rxjs'; +import { ConfiguratorTextfieldService } from '../../core/facade/configurator-textfield.service'; +import { ConfiguratorTextfield } from '../../core/model/configurator-textfield.model'; +import { ConfiguratorTextfieldAddToCartButtonComponent } from './configurator-textfield-add-to-cart-button.component'; + +const PRODUCT_CODE = 'CONF_LAPTOP'; +const URL_CONFIGURATION = 'host:port/electronics-spa/en/USD/configureTEXTFIELD'; +const OWNER: CommonConfigurator.Owner = { + type: CommonConfigurator.OwnerType.PRODUCT, + id: PRODUCT_CODE, +}; +const configurationTextField: ConfiguratorTextfield.Configuration = { + configurationInfos: [], + owner: OWNER, +}; +const mockRouterState: any = { + state: { + url: URL_CONFIGURATION, + params: { + entityKey: PRODUCT_CODE, + ownerType: CommonConfigurator.OwnerType.PRODUCT, + }, + queryParams: {}, + }, +}; + +class MockRoutingService { + getRouterState(): Observable { + return of(mockRouterState); + } + go(): void {} +} + +class MockConfiguratorTextfieldService { + addToCart(): void {} + updateCartEntry(): void {} +} + +@Pipe({ + name: 'cxUrl', +}) +class MockUrlPipe implements PipeTransform { + transform(): any {} +} + +describe('ConfigTextfieldAddToCartButtonComponent', () => { + let classUnderTest: ConfiguratorTextfieldAddToCartButtonComponent; + let fixture: ComponentFixture; + let textfieldService: ConfiguratorTextfieldService; + + let htmlElem: HTMLElement; + + function checkButtonText(buttonText: string): void { + fixture.detectChanges(); + const buttonElements = htmlElem.getElementsByClassName( + 'cx-btn btn btn-block btn-primary cx-add-to-cart-btn' + ); + expect(buttonElements).toBeDefined(); + expect(buttonElements.length).toBe(1); + expect(buttonElements[0].textContent.trim()).toBe(buttonText); + } + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [I18nTestingModule], + declarations: [ + ConfiguratorTextfieldAddToCartButtonComponent, + MockUrlPipe, + ], + providers: [ + { + provide: ConfiguratorTextfieldService, + useClass: MockConfiguratorTextfieldService, + }, + { + provide: RoutingService, + useClass: MockRoutingService, + }, + ], + }) + .overrideComponent(ConfiguratorTextfieldAddToCartButtonComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent( + ConfiguratorTextfieldAddToCartButtonComponent + ); + classUnderTest = fixture.componentInstance; + classUnderTest.configuration = configurationTextField; + htmlElem = fixture.nativeElement; + textfieldService = TestBed.inject( + ConfiguratorTextfieldService as Type + ); + + OWNER.type = CommonConfigurator.OwnerType.PRODUCT; + mockRouterState.state.params.ownerType = + CommonConfigurator.OwnerType.PRODUCT; + }); + + it('should create component', () => { + expect(classUnderTest).toBeTruthy(); + expect(classUnderTest.configuration).toBe(configurationTextField); + }); + + it('should display addToCart text because router points to owner product initially', () => { + checkButtonText('configurator.addToCart.button'); + }); + + it('should display "done" text in case router points to cart entry', () => { + classUnderTest.configuration.owner.type = + CommonConfigurator.OwnerType.CART_ENTRY; + checkButtonText('configurator.addToCart.buttonUpdateCart'); + }); + + it('should navigate to cart and call addToCart on core service when onAddToCart was triggered ', () => { + spyOn(textfieldService, 'addToCart').and.callThrough(); + + classUnderTest.onAddToCart(); + + expect(textfieldService.addToCart).toHaveBeenCalledWith( + OWNER.id, + configurationTextField + ); + }); + + it('should navigate to cart when onAddToCart was triggered and owner points to cart entry ', () => { + OWNER.type = CommonConfigurator.OwnerType.CART_ENTRY; + + spyOn(textfieldService, 'updateCartEntry').and.callThrough(); + + classUnderTest.onAddToCart(); + expect(textfieldService.updateCartEntry).toHaveBeenCalledWith( + OWNER.id, + configurationTextField + ); + }); +}); diff --git a/feature-libs/product-configurator/textfield/components/add-to-cart-button/configurator-textfield-add-to-cart-button.component.ts b/feature-libs/product-configurator/textfield/components/add-to-cart-button/configurator-textfield-add-to-cart-button.component.ts new file mode 100644 index 00000000000..9e40aa2fba4 --- /dev/null +++ b/feature-libs/product-configurator/textfield/components/add-to-cart-button/configurator-textfield-add-to-cart-button.component.ts @@ -0,0 +1,50 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { ConfiguratorTextfieldService } from '../../core/facade/configurator-textfield.service'; +import { ConfiguratorTextfield } from '../../core/model/configurator-textfield.model'; + +@Component({ + selector: 'cx-configurator-textfield-add-to-cart-button', + templateUrl: './configurator-textfield-add-to-cart-button.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorTextfieldAddToCartButtonComponent { + @Input() configuration: ConfiguratorTextfield.Configuration; + @Input() productCode: string; + + constructor( + protected configuratorTextfieldService: ConfiguratorTextfieldService + ) {} + + /** + * Adds the textfield configuration to the cart or updates it + */ + onAddToCart(): void { + const owner: CommonConfigurator.Owner = this.configuration.owner; + switch (owner.type) { + case CommonConfigurator.OwnerType.PRODUCT: + this.configuratorTextfieldService.addToCart( + owner.id, + this.configuration + ); + break; + case CommonConfigurator.OwnerType.CART_ENTRY: + this.configuratorTextfieldService.updateCartEntry( + owner.id, + this.configuration + ); + break; + } + } + + /** + * Returns button description. Button will display 'addToCart' or 'done' in case configuration indicates that owner is a cart entry + * @returns Resource key of button description + */ + getButtonText(): string { + return this.configuration.owner.type === + CommonConfigurator.OwnerType.CART_ENTRY + ? 'configurator.addToCart.buttonUpdateCart' + : 'configurator.addToCart.button'; + } +} diff --git a/feature-libs/product-configurator/textfield/components/form/configurator-textfield-form.component.html b/feature-libs/product-configurator/textfield/components/form/configurator-textfield-form.component.html new file mode 100644 index 00000000000..b4b464fd3eb --- /dev/null +++ b/feature-libs/product-configurator/textfield/components/form/configurator-textfield-form.component.html @@ -0,0 +1,14 @@ + +
+ +
+ +
diff --git a/feature-libs/product-configurator/textfield/components/form/configurator-textfield-form.component.spec.ts b/feature-libs/product-configurator/textfield/components/form/configurator-textfield-form.component.spec.ts new file mode 100644 index 00000000000..03ed78c52f5 --- /dev/null +++ b/feature-libs/product-configurator/textfield/components/form/configurator-textfield-form.component.spec.ts @@ -0,0 +1,139 @@ +import { Type } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { + I18nTestingModule, + RouterState, + RoutingService, +} from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { PageLayoutModule } from '@spartacus/storefront'; +import { cold } from 'jasmine-marbles'; +import { Observable } from 'rxjs'; +import { ConfiguratorTextfieldService } from '../../core/facade/configurator-textfield.service'; +import { ConfiguratorTextfield } from '../../core/model/configurator-textfield.model'; +import { ConfiguratorTextfieldAddToCartButtonComponent } from '../add-to-cart-button/configurator-textfield-add-to-cart-button.component'; +import { ConfiguratorTextfieldInputFieldComponent } from '../input-field/configurator-textfield-input-field.component'; +import { ConfiguratorTextfieldFormComponent } from './configurator-textfield-form.component'; + +const PRODUCT_CODE = 'CONF_LAPTOP'; +const CART_ENTRY_KEY = '3'; +const ATTRIBUTE_NAME = 'AttributeName'; +const ROUTE_CONFIGURATION = 'configureTEXTFIELD'; +const mockRouterState: any = { + state: { + params: { + ownerType: CommonConfigurator.OwnerType.PRODUCT, + entityKey: PRODUCT_CODE, + }, + semanticRoute: ROUTE_CONFIGURATION, + }, +}; +const productConfig: ConfiguratorTextfield.Configuration = { + configurationInfos: [{ configurationLabel: ATTRIBUTE_NAME }], +}; +class MockRoutingService { + getRouterState(): Observable { + return cold('-r', { + r: mockRouterState, + }); + } +} + +class MockConfiguratorTextfieldService { + createConfiguration(): Observable { + return cold('-p', { + p: productConfig, + }); + } + updateConfiguration(): void {} + readConfigurationForCartEntry(): Observable< + ConfiguratorTextfield.Configuration + > { + return cold('-p', { + p: productConfig, + }); + } +} +describe('TextfieldFormComponent', () => { + let component: ConfiguratorTextfieldFormComponent; + let fixture: ComponentFixture; + let textfieldService: ConfiguratorTextfieldService; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + I18nTestingModule, + ReactiveFormsModule, + NgSelectModule, + PageLayoutModule, + ], + declarations: [ + ConfiguratorTextfieldFormComponent, + ConfiguratorTextfieldInputFieldComponent, + ConfiguratorTextfieldAddToCartButtonComponent, + ], + providers: [ + { + provide: RoutingService, + useClass: MockRoutingService, + }, + { + provide: ConfiguratorTextfieldService, + useClass: MockConfiguratorTextfieldService, + }, + ], + }).compileComponents(); + }) + ); + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorTextfieldFormComponent); + component = fixture.componentInstance; + textfieldService = TestBed.inject( + ConfiguratorTextfieldService as Type + ); + }); + + it('should create component', () => { + expect(component).toBeDefined(); + }); + + it('should know product configuration after init has been done', () => { + mockRouterState.state = { + params: { + ownerType: CommonConfigurator.OwnerType.PRODUCT, + entityKey: PRODUCT_CODE, + }, + semanticRoute: ROUTE_CONFIGURATION, + }; + expect(component.configuration$).toBeObservable( + cold('--p', { + p: productConfig, + }) + ); + }); + + it('should know product configuration after init when starting from cart', () => { + mockRouterState.state = { + params: { + ownerType: CommonConfigurator.OwnerType.CART_ENTRY, + entityKey: CART_ENTRY_KEY, + }, + semanticRoute: ROUTE_CONFIGURATION, + }; + + expect(component.configuration$).toBeObservable( + cold('--p', { + p: productConfig, + }) + ); + }); + + it('should call update configuration on facade in case it was triggered on component', () => { + spyOn(textfieldService, 'updateConfiguration').and.callThrough(); + component.updateConfiguration(productConfig.configurationInfos[0]); + expect(textfieldService.updateConfiguration).toHaveBeenCalledTimes(1); + }); +}); diff --git a/feature-libs/product-configurator/textfield/components/form/configurator-textfield-form.component.ts b/feature-libs/product-configurator/textfield/components/form/configurator-textfield-form.component.ts new file mode 100644 index 00000000000..8404cf55f12 --- /dev/null +++ b/feature-libs/product-configurator/textfield/components/form/configurator-textfield-form.component.ts @@ -0,0 +1,47 @@ +import { Component } from '@angular/core'; +import { + CommonConfigurator, + ConfiguratorRouterExtractorService, +} from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { ConfiguratorTextfieldService } from '../../core/facade/configurator-textfield.service'; +import { ConfiguratorTextfield } from '../../core/model/configurator-textfield.model'; + +@Component({ + selector: 'cx-configurator-textfield-form', + templateUrl: './configurator-textfield-form.component.html', +}) +export class ConfiguratorTextfieldFormComponent { + configuration$: Observable< + ConfiguratorTextfield.Configuration + > = this.configRouterExtractorService.extractRouterData().pipe( + switchMap((routerData) => { + switch (routerData.owner.type) { + case CommonConfigurator.OwnerType.PRODUCT: + return this.configuratorTextfieldService.createConfiguration( + routerData.owner + ); + case CommonConfigurator.OwnerType.CART_ENTRY: + return this.configuratorTextfieldService.readConfigurationForCartEntry( + routerData.owner + ); + } + }) + ); + + constructor( + protected configuratorTextfieldService: ConfiguratorTextfieldService, + protected configRouterExtractorService: ConfiguratorRouterExtractorService + ) {} + + /** + * Updates a configuration attribute + * @param attribute Configuration attribute, always containing a string typed value + */ + updateConfiguration( + attribute: ConfiguratorTextfield.ConfigurationInfo + ): void { + this.configuratorTextfieldService.updateConfiguration(attribute); + } +} diff --git a/feature-libs/product-configurator/textfield/components/index.ts b/feature-libs/product-configurator/textfield/components/index.ts new file mode 100644 index 00000000000..5922a10edb2 --- /dev/null +++ b/feature-libs/product-configurator/textfield/components/index.ts @@ -0,0 +1,4 @@ +export * from './add-to-cart-button/configurator-textfield-add-to-cart-button.component'; +export * from './form/configurator-textfield-form.component'; +export * from './input-field/configurator-textfield-input-field.component'; +export * from './textfield-configurator-components.module'; diff --git a/feature-libs/product-configurator/textfield/components/input-field/configurator-textfield-input-field.component.html b/feature-libs/product-configurator/textfield/components/input-field/configurator-textfield-input-field.component.html new file mode 100644 index 00000000000..2daf06b12f1 --- /dev/null +++ b/feature-libs/product-configurator/textfield/components/input-field/configurator-textfield-input-field.component.html @@ -0,0 +1,14 @@ + +
+ +
diff --git a/feature-libs/product-configurator/textfield/components/input-field/configurator-textfield-input-field.component.spec.ts b/feature-libs/product-configurator/textfield/components/input-field/configurator-textfield-input-field.component.spec.ts new file mode 100644 index 00000000000..425d5304d7b --- /dev/null +++ b/feature-libs/product-configurator/textfield/components/input-field/configurator-textfield-input-field.component.spec.ts @@ -0,0 +1,57 @@ +import { ChangeDetectionStrategy } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ConfiguratorTextfieldInputFieldComponent } from './configurator-textfield-input-field.component'; + +describe('TextfieldInputFieldComponent', () => { + let component: ConfiguratorTextfieldInputFieldComponent; + + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ConfiguratorTextfieldInputFieldComponent], + imports: [ReactiveFormsModule], + }) + .overrideComponent(ConfiguratorTextfieldInputFieldComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfiguratorTextfieldInputFieldComponent); + component = fixture.componentInstance; + component.attribute = { + configurationLabel: 'attributeName', + configurationValue: 'input123', + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set value on init', () => { + expect(component.attributeInputForm.value).toEqual('input123'); + }); + + it('should emit a change event on change ', () => { + spyOn(component.inputChange, 'emit').and.callThrough(); + component.onInputChange(); + expect(component.inputChange.emit).toHaveBeenCalledWith( + component.attribute + ); + }); + + it('should generate id with prefixt', () => { + expect(component.getId(component.attribute)).toEqual( + 'cx-configurator-textfieldattributeName' + ); + }); +}); diff --git a/feature-libs/product-configurator/textfield/components/input-field/configurator-textfield-input-field.component.ts b/feature-libs/product-configurator/textfield/components/input-field/configurator-textfield-input-field.component.ts new file mode 100644 index 00000000000..86dfdc1a397 --- /dev/null +++ b/feature-libs/product-configurator/textfield/components/input-field/configurator-textfield-input-field.component.ts @@ -0,0 +1,68 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { ConfiguratorTextfield } from '../../core/model/configurator-textfield.model'; + +@Component({ + selector: 'cx-configurator-textfield-input-field', + templateUrl: './configurator-textfield-input-field.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfiguratorTextfieldInputFieldComponent implements OnInit { + PREFIX_TEXTFIELD = 'cx-configurator-textfield'; + attributeInputForm = new FormControl(''); + + @Input() attribute: ConfiguratorTextfield.ConfigurationInfo; + @Output() inputChange = new EventEmitter< + ConfiguratorTextfield.ConfigurationInfo + >(); + + constructor() {} + + ngOnInit() { + this.attributeInputForm.setValue(this.attribute.configurationValue); + } + /** + * Triggered if an attribute value is changed. Triggers the emission of the inputChange event emitter that is + * in turn received in the form component + */ + onInputChange(): void { + const attribute: ConfiguratorTextfield.ConfigurationInfo = { + configurationLabel: this.attribute.configurationLabel, + configurationValue: this.attributeInputForm.value, + }; + + this.inputChange.emit(attribute); + } + /** + * Compiles an ID for the attribute label by using the label from the backend and a prefix 'label' + * @param attribute Textfield configurator attribute. Carries the attribute label information from the backend + * @returns ID + */ + getIdLabel(attribute: ConfiguratorTextfield.ConfigurationInfo): string { + return ( + this.PREFIX_TEXTFIELD + 'label' + this.getLabelForIdGeneration(attribute) + ); + } + /** + * Compiles an ID for the attribute value by using the label from the backend + * @param attribute Textfield configurator attribute. Carries the attribute label information from the backend + * @returns ID + */ + getId(attribute: ConfiguratorTextfield.ConfigurationInfo): string { + return this.PREFIX_TEXTFIELD + this.getLabelForIdGeneration(attribute); + } + + protected getLabelForIdGeneration( + attribute: ConfiguratorTextfield.ConfigurationInfo + ): string { + //replace white spaces with an empty string + return attribute.configurationLabel.replace(/\s/g, ''); + } +} diff --git a/feature-libs/product-configurator/textfield/components/textfield-configurator-components.module.ts b/feature-libs/product-configurator/textfield/components/textfield-configurator-components.module.ts new file mode 100644 index 00000000000..1b42950d443 --- /dev/null +++ b/feature-libs/product-configurator/textfield/components/textfield-configurator-components.module.ts @@ -0,0 +1,51 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { + CmsConfig, + I18nModule, + provideDefaultConfig, + UrlModule, +} from '@spartacus/core'; +import { ConfiguratorTextfieldAddToCartButtonComponent } from './add-to-cart-button/configurator-textfield-add-to-cart-button.component'; +import { ConfiguratorTextfieldFormComponent } from './form/configurator-textfield-form.component'; +import { ConfiguratorTextfieldInputFieldComponent } from './input-field/configurator-textfield-input-field.component'; + +@NgModule({ + imports: [ + RouterModule, + FormsModule, + ReactiveFormsModule, + NgSelectModule, + CommonModule, + I18nModule, + UrlModule, + ], + providers: [ + provideDefaultConfig({ + cmsComponents: { + TextfieldConfigurationForm: { + component: ConfiguratorTextfieldFormComponent, + }, + }, + }), + ], + declarations: [ + ConfiguratorTextfieldFormComponent, + ConfiguratorTextfieldInputFieldComponent, + ConfiguratorTextfieldAddToCartButtonComponent, + ], + exports: [ + ConfiguratorTextfieldFormComponent, + ConfiguratorTextfieldInputFieldComponent, + ConfiguratorTextfieldAddToCartButtonComponent, + ], + entryComponents: [ + ConfiguratorTextfieldFormComponent, + ConfiguratorTextfieldInputFieldComponent, + ConfiguratorTextfieldAddToCartButtonComponent, + ], +}) +export class TextfieldConfiguratorComponentsModule {} diff --git a/feature-libs/product-configurator/textfield/core/connectors/configurator-textfield.adapter.ts b/feature-libs/product-configurator/textfield/core/connectors/configurator-textfield.adapter.ts new file mode 100644 index 00000000000..6dac748e0d6 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/connectors/configurator-textfield.adapter.ts @@ -0,0 +1,50 @@ +import { CartModification } from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { ConfiguratorTextfield } from '../model/configurator-textfield.model'; + +export abstract class ConfiguratorTextfieldAdapter { + /** + * Abstract method used to create a default configuration based on product code + * and owner + * + * @param productCode Root product code + * @param owner Configuration owner + * @returns Observable of configurations + */ + abstract createConfiguration( + productCode: string, + owner: CommonConfigurator.Owner + ): Observable; + + /** + * Abstract method to add a configuration to cart, based on a product, a configuration, + * and other attributes part of parameters + * + * @param parameters add to cart parameters object + * @returns Observable of cart modifications + */ + abstract addToCart( + parameters: ConfiguratorTextfield.AddToCartParameters + ): Observable; + + /** + * Abstract method to read a configuration for a cart entry + * + * @param parameters read from cart entry parameters object + * @returns Observable of configurations + */ + abstract readConfigurationForCartEntry( + parameters: CommonConfigurator.ReadConfigurationFromCartEntryParameters + ): Observable; + + /** + * Abstract method to update a configuration attached to a cart entry + * + * @param parameters contains attributes needed to update the cart entries' configuration + * @returns Observable of cart modifications + */ + abstract updateConfigurationForCartEntry( + parameters: ConfiguratorTextfield.UpdateCartEntryParameters + ): Observable; +} diff --git a/feature-libs/product-configurator/textfield/core/connectors/configurator-textfield.connector.spec.ts b/feature-libs/product-configurator/textfield/core/connectors/configurator-textfield.connector.spec.ts new file mode 100644 index 00000000000..89f25892dd1 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/connectors/configurator-textfield.connector.spec.ts @@ -0,0 +1,131 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { of } from 'rxjs'; +import { ConfiguratorTextfield } from '../model/configurator-textfield.model'; +import { ConfiguratorTextfieldAdapter } from './configurator-textfield.adapter'; +import { ConfiguratorTextfieldConnector } from './configurator-textfield.connector'; + +import createSpy = jasmine.createSpy; + +const USER_ID = 'theUser'; +const CART_ID = '98876'; + +class MockConfiguratorTextfieldAdapter implements ConfiguratorTextfieldAdapter { + readConfiguration = createSpy().and.callFake((configId) => + of('readConfiguration' + configId) + ); + + updateConfiguration = createSpy().and.callFake((configuration) => + of('updateConfiguration' + configuration.configId) + ); + + createConfiguration = createSpy().and.callFake((productCode) => + of('createConfiguration' + productCode) + ); + + addToCart = createSpy().and.callFake((params) => of('addToCart' + params)); + + updateConfigurationForCartEntry = createSpy().and.callFake((params) => + of('updateConfigurationForCartEntry' + params) + ); + + readConfigurationForCartEntry = createSpy().and.callFake((params) => + of('readConfigurationForCartEntry' + params) + ); +} + +describe('ConfiguratorTextfieldConnector', () => { + let service: ConfiguratorTextfieldConnector; + const PRODUCT_CODE = 'CONF_LAPTOP'; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: ConfiguratorTextfieldAdapter, + useClass: MockConfiguratorTextfieldAdapter, + }, + { + provide: ConfiguratorTextfieldConnector, + useClass: ConfiguratorTextfieldConnector, + }, + ], + }); + + service = TestBed.inject( + ConfiguratorTextfieldConnector as Type + ); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call adapter on createConfiguration', () => { + const adapter = TestBed.inject( + ConfiguratorTextfieldAdapter as Type + ); + + let result; + service + .createConfiguration(PRODUCT_CODE, null) + .subscribe((res) => (result = res)); + expect(result).toBe('createConfiguration' + PRODUCT_CODE); + expect(adapter.createConfiguration).toHaveBeenCalledWith( + PRODUCT_CODE, + null + ); + }); + + it('should call adapter on readConfigurationForCartEntry', () => { + const adapter = TestBed.inject( + ConfiguratorTextfieldAdapter as Type + ); + + const params: CommonConfigurator.ReadConfigurationFromCartEntryParameters = {}; + let result; + service + .readConfigurationForCartEntry(params) + .subscribe((res) => (result = res)); + expect(result).toBe('readConfigurationForCartEntry' + params); + expect(adapter.readConfigurationForCartEntry).toHaveBeenCalledWith(params); + }); + + it('should call adapter on addToCart', () => { + const adapter = TestBed.inject( + ConfiguratorTextfieldAdapter as Type + ); + + const parameters: ConfiguratorTextfield.AddToCartParameters = { + userId: USER_ID, + cartId: CART_ID, + productCode: PRODUCT_CODE, + quantity: 1, + }; + let result; + service.addToCart(parameters).subscribe((res) => (result = res)); + expect(adapter.addToCart).toHaveBeenCalledWith(parameters); + expect(result).toBe('addToCart' + parameters); + }); + + it('should call adapter on updateCartEntry', () => { + const adapter = TestBed.inject( + ConfiguratorTextfieldAdapter as Type + ); + + const parameters: ConfiguratorTextfield.UpdateCartEntryParameters = { + userId: USER_ID, + cartId: CART_ID, + cartEntryNumber: '1', + }; + let result; + service + .updateConfigurationForCartEntry(parameters) + .subscribe((res) => (result = res)); + expect(adapter.updateConfigurationForCartEntry).toHaveBeenCalledWith( + parameters + ); + expect(result).toBe('updateConfigurationForCartEntry' + parameters); + }); +}); diff --git a/feature-libs/product-configurator/textfield/core/connectors/configurator-textfield.connector.ts b/feature-libs/product-configurator/textfield/core/connectors/configurator-textfield.connector.ts new file mode 100644 index 00000000000..9e56fb4fb9c --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/connectors/configurator-textfield.connector.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; +import { CartModification } from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { ConfiguratorTextfield } from '../model/configurator-textfield.model'; +import { ConfiguratorTextfieldAdapter } from './configurator-textfield.adapter'; + +@Injectable() +export class ConfiguratorTextfieldConnector { + constructor(protected adapter: ConfiguratorTextfieldAdapter) {} + + /** + * Creates default configuration for a product that is textfield-configurable + * @param productCode Product code + * @param owner Owner of the configuration + * @returns Observable of product configurations + */ + createConfiguration( + productCode: string, + owner: CommonConfigurator.Owner + ): Observable { + return this.adapter.createConfiguration(productCode, owner); + } + /** + * Reads an existing configuration for a cart entry + * @param parameters Attributes needed to read a product configuration for a cart entry + * @returns Observable of product configurations + */ + readConfigurationForCartEntry( + parameters: CommonConfigurator.ReadConfigurationFromCartEntryParameters + ): Observable { + return this.adapter.readConfigurationForCartEntry(parameters); + } + /** + * Updates a configuration that is attached to a cart entry + * @param parameters Attributes needed to update a cart entries' configuration + * @returns Observable of cart modifications + */ + updateConfigurationForCartEntry( + parameters: ConfiguratorTextfield.UpdateCartEntryParameters + ): Observable { + return this.adapter.updateConfigurationForCartEntry(parameters); + } + + /** + * Adds a textfield-configurable product to the cart, and passes along its configuration + * @param parameters Attributes needed to add a textfield product along with its configuration to the cart + * @returns Observable of cart modifications + */ + addToCart( + parameters: ConfiguratorTextfield.AddToCartParameters + ): Observable { + return this.adapter.addToCart(parameters); + } +} diff --git a/feature-libs/product-configurator/textfield/core/connectors/converters.ts b/feature-libs/product-configurator/textfield/core/connectors/converters.ts new file mode 100644 index 00000000000..a43055e9535 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/connectors/converters.ts @@ -0,0 +1,15 @@ +import { InjectionToken } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { ConfiguratorTextfield } from '../model/configurator-textfield.model'; + +export const CONFIGURATION_TEXTFIELD_NORMALIZER = new InjectionToken< + Converter +>('ConfigurationNormalizer'); + +export const CONFIGURATION_TEXTFIELD_ADD_TO_CART_SERIALIZER = new InjectionToken< + Converter +>('ConfigurationAddToCartSerializer'); + +export const CONFIGURATION_TEXTFIELD_UPDATE_CART_ENTRY_SERIALIZER = new InjectionToken< + Converter +>('ConfigurationUpdateCartEntrySerializer'); diff --git a/feature-libs/product-configurator/textfield/core/connectors/index.ts b/feature-libs/product-configurator/textfield/core/connectors/index.ts new file mode 100644 index 00000000000..3ac1e9b2efe --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/connectors/index.ts @@ -0,0 +1,3 @@ +export * from './configurator-textfield.adapter'; +export * from './configurator-textfield.connector'; +export * from './converters'; diff --git a/feature-libs/product-configurator/textfield/core/facade/configurator-textfield.service.spec.ts b/feature-libs/product-configurator/textfield/core/facade/configurator-textfield.service.spec.ts new file mode 100644 index 00000000000..1a42be95c59 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/facade/configurator-textfield.service.spec.ts @@ -0,0 +1,294 @@ +import { Type } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import * as ngrxStore from '@ngrx/store'; +import { Store, StoreModule } from '@ngrx/store'; +import { + ActiveCartService, + Cart, + OCC_USER_ID_ANONYMOUS, +} from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { of } from 'rxjs'; +import { ConfiguratorTextfield } from '../model/configurator-textfield.model'; +import { ConfiguratorTextfieldActions } from '../state/actions/index'; +import { + ConfigurationTextfieldState, + StateWithConfigurationTextfield, +} from '../state/configuration-textfield-state'; +import { ConfiguratorTextfieldService } from './configurator-textfield.service'; +import createSpy = jasmine.createSpy; + +const PRODUCT_CODE = 'CONF_LAPTOP'; + +const ATTRIBUTE_NAME = 'AttributeName'; +const ATTRIBUTE_VALUE = 'AttributeValue'; + +const CHANGED_VALUE = 'theNewValue'; +const CART_CODE = '0000009336'; +const CART_GUID = 'e767605d-7336-48fd-b156-ad50d004ca10'; +const CART_ENTRY_NUMBER = '2'; +const owner: CommonConfigurator.Owner = { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.PRODUCT, +}; + +const ownerCartRelated: CommonConfigurator.Owner = { + id: CART_ENTRY_NUMBER, + type: CommonConfigurator.OwnerType.CART_ENTRY, +}; +const readFromCartEntryParams: CommonConfigurator.ReadConfigurationFromCartEntryParameters = { + userId: 'anonymous', + cartId: CART_GUID, + cartEntryNumber: CART_ENTRY_NUMBER, + owner: ownerCartRelated, +}; + +const productConfiguration: ConfiguratorTextfield.Configuration = { + configurationInfos: [ + { configurationLabel: ATTRIBUTE_NAME, configurationValue: ATTRIBUTE_VALUE }, + ], + owner: { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.PRODUCT, + }, +}; + +const loaderState: ConfigurationTextfieldState = { + loaderState: { value: productConfiguration }, +}; + +const loaderStateNothingPresent: ConfigurationTextfieldState = { + loaderState: { value: undefined }, +}; + +const updateCartEntryParams: ConfiguratorTextfield.UpdateCartEntryParameters = { + cartId: CART_GUID, + userId: OCC_USER_ID_ANONYMOUS, + cartEntryNumber: CART_ENTRY_NUMBER, + configuration: productConfiguration, +}; + +const changedAttribute: ConfiguratorTextfield.ConfigurationInfo = { + configurationLabel: ATTRIBUTE_NAME, + configurationValue: CHANGED_VALUE, +}; + +const changedProductConfiguration: ConfiguratorTextfield.Configuration = { + configurationInfos: [ + { + configurationLabel: ATTRIBUTE_NAME, + configurationValue: CHANGED_VALUE, + status: ConfiguratorTextfield.ConfigurationStatus.SUCCESS, + }, + ], + owner: { + id: PRODUCT_CODE, + type: CommonConfigurator.OwnerType.PRODUCT, + }, +}; + +const cart: Cart = { + code: CART_CODE, + guid: CART_GUID, + user: { uid: OCC_USER_ID_ANONYMOUS }, +}; + +const cartState: any = { + value: cart, +}; + +class MockActiveCartService { + requireLoadedCart(): any { + return of(cartState); + } +} + +describe('ConfiguratorTextfieldService', () => { + let serviceUnderTest: ConfiguratorTextfieldService; + let store: Store; + const mockConfigLoaderStateReturned = createSpy( + 'select' + ).and.returnValue(() => of(loaderState)); + const mockConfigLoaderStateNothingPresent = createSpy( + 'select' + ).and.returnValue(() => of(loaderStateNothingPresent)); + const mockConfigReturned = createSpy('select').and.returnValue(() => + of(productConfiguration) + ); + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({})], + providers: [ + ConfiguratorTextfieldService, + { + provide: ActiveCartService, + useClass: MockActiveCartService, + }, + ], + }).compileComponents(); + }) + ); + beforeEach(() => { + serviceUnderTest = TestBed.inject( + ConfiguratorTextfieldService as Type + ); + store = TestBed.inject( + Store as Type> + ); + + spyOn(store, 'dispatch').and.callThrough(); + }); + + it('should create service', () => { + expect(serviceUnderTest).toBeDefined(); + }); + + it('should return a configuration if one is present on createConfiguration', () => { + spyOnProperty(ngrxStore, 'select').and.returnValues( + mockConfigLoaderStateReturned + ); + const configurationFromStore = serviceUnderTest.createConfiguration(owner); + + expect(configurationFromStore).toBeDefined(); + + configurationFromStore + .subscribe((configuration) => + expect(configuration.configurationInfos.length).toBe(1) + ) + .unsubscribe(); + }); + + it('should create a configuration if nothing is present in store yet', () => { + spyOnProperty(ngrxStore, 'select').and.returnValues( + mockConfigLoaderStateNothingPresent + ); + const configurationFromStore = serviceUnderTest.createConfiguration(owner); + + expect(configurationFromStore).toBeDefined(); + + configurationFromStore + .subscribe(() => + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorTextfieldActions.CreateConfiguration({ + productCode: owner.id, + owner: owner, + }) + ) + ) + .unsubscribe(); + }); + + it('should dispatch the correct action when readFromCartEntry is called', () => { + spyOnProperty(ngrxStore, 'select').and.returnValues(mockConfigReturned); + const configurationFromStore = serviceUnderTest.readConfigurationForCartEntry( + ownerCartRelated + ); + + expect(configurationFromStore).toBeDefined(); + + configurationFromStore + .subscribe((configuration) => + expect(configuration.configurationInfos.length).toBe(1) + ) + .unsubscribe(); + + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorTextfieldActions.ReadCartEntryConfiguration( + readFromCartEntryParams + ) + ); + }); + + it('should access the store when calling createConfiguration', () => { + spyOnProperty(ngrxStore, 'select').and.returnValues( + mockConfigLoaderStateReturned + ); + serviceUnderTest + .createConfiguration(owner) + .subscribe((configurationFromStore) => + expect(configurationFromStore).toBe(productConfiguration) + ) + .unsubscribe(); + }); + + it('should update a configuration, accessing the store', () => { + spyOnProperty(ngrxStore, 'select').and.returnValues(mockConfigReturned); + spyOn( + serviceUnderTest, + 'createNewConfigurationWithChange' + ).and.callThrough(); + + serviceUnderTest.updateConfiguration(changedAttribute); + + expect( + serviceUnderTest.createNewConfigurationWithChange + ).toHaveBeenCalledWith(changedAttribute, productConfiguration); + + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorTextfieldActions.UpdateConfiguration( + changedProductConfiguration + ) + ); + }); + + it('should create new configuration with changed value', () => { + const attribute: ConfiguratorTextfield.ConfigurationInfo = { + configurationLabel: ATTRIBUTE_NAME, + configurationValue: CHANGED_VALUE, + }; + const result = serviceUnderTest.createNewConfigurationWithChange( + attribute, + productConfiguration + ); + + expect(result).toBeDefined(); + expect(result.configurationInfos[0].configurationValue).toBe(CHANGED_VALUE); + expect(result.configurationInfos[0].status).toBe( + ConfiguratorTextfield.ConfigurationStatus.SUCCESS + ); + }); + + it('should create new configuration with same value if label could not be found', () => { + const unknownAttribute: ConfiguratorTextfield.ConfigurationInfo = { + configurationLabel: 'unknownLabel', + }; + const result = serviceUnderTest.createNewConfigurationWithChange( + unknownAttribute, + productConfiguration + ); + + expect(result).toBeDefined(); + expect(result.configurationInfos[0].configurationValue).toBe( + ATTRIBUTE_VALUE + ); + }); + + it('should dispatch addToCart action with correct parameters on calling addToCart', () => { + const addToCartParams: ConfiguratorTextfield.AddToCartParameters = { + cartId: CART_GUID, + userId: OCC_USER_ID_ANONYMOUS, + productCode: PRODUCT_CODE, + configuration: productConfiguration, + quantity: 1, + }; + + const addToCartAction = new ConfiguratorTextfieldActions.AddToCart( + addToCartParams + ); + + serviceUnderTest.addToCart(PRODUCT_CODE, productConfiguration); + + expect(store.dispatch).toHaveBeenCalledWith(addToCartAction); + }); + + it('should dispatch corresponding action in case updateCartEntry was triggered', () => { + serviceUnderTest.updateCartEntry(CART_ENTRY_NUMBER, productConfiguration); + expect(store.dispatch).toHaveBeenCalledWith( + new ConfiguratorTextfieldActions.UpdateCartEntryConfiguration( + updateCartEntryParams + ) + ); + }); +}); diff --git a/feature-libs/product-configurator/textfield/core/facade/configurator-textfield.service.ts b/feature-libs/product-configurator/textfield/core/facade/configurator-textfield.service.ts new file mode 100644 index 00000000000..f183267bc1d --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/facade/configurator-textfield.service.ts @@ -0,0 +1,201 @@ +import { Injectable } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { ActiveCartService } from '@spartacus/core'; +import { + CommonConfigurator, + CommonConfiguratorUtilsService, +} from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { filter, map, switchMapTo, take, tap } from 'rxjs/operators'; +import { ConfiguratorTextfield } from '../model/configurator-textfield.model'; +import { ConfiguratorTextfieldActions } from '../state/actions/index'; +import { StateWithConfigurationTextfield } from '../state/configuration-textfield-state'; +import { ConfiguratorTextFieldSelectors } from '../state/selectors/index'; + +@Injectable({ + providedIn: 'root', +}) +export class ConfiguratorTextfieldService { + constructor( + protected store: Store, + protected activeCartService: ActiveCartService, + protected configuratorUtils: CommonConfiguratorUtilsService + ) {} + + /** + * Creates a default textfield configuration for a product specified by the configuration owner. + * + * @param owner - Configuration owner + * + * @returns {Observable} + */ + createConfiguration( + owner: CommonConfigurator.Owner + ): Observable { + return this.store.pipe( + select(ConfiguratorTextFieldSelectors.getConfigurationsState), + tap((configurationState) => { + const isAvailableForProduct = + configurationState.loaderState.value?.owner.type === + CommonConfigurator.OwnerType.PRODUCT; + const isLoading = configurationState.loaderState.loading; + if (!isAvailableForProduct && !isLoading) { + this.store.dispatch( + new ConfiguratorTextfieldActions.CreateConfiguration({ + productCode: owner.id, //owner Id is the product code in this case + owner: owner, + }) + ); + } + }), + map((configurationState) => configurationState.loaderState.value), + filter((configuration) => !this.isConfigurationInitial(configuration)) + ); + } + + /** + * Updates a textfield configuration, specified by the changed attribute. + * + * @param changedAttribute - Changed attribute + */ + updateConfiguration( + changedAttribute: ConfiguratorTextfield.ConfigurationInfo + ): void { + this.store + .pipe( + select(ConfiguratorTextFieldSelectors.getConfigurationContent), + take(1) + ) + .subscribe((oldConfiguration) => { + this.store.dispatch( + new ConfiguratorTextfieldActions.UpdateConfiguration( + this.createNewConfigurationWithChange( + changedAttribute, + oldConfiguration + ) + ) + ); + }); + } + + /** + * Adds the textfield configuration to the cart + * + * @param productCode - Product code of the configuration root product. Cart entry carries refers to this product + * @param configuration Textfield configuration + */ + addToCart( + productCode: string, + configuration: ConfiguratorTextfield.Configuration + ): void { + this.activeCartService + .requireLoadedCart() + .pipe(take(1)) + .subscribe((cartState) => { + const addToCartParameters: ConfiguratorTextfield.AddToCartParameters = { + userId: this.configuratorUtils.getUserId(cartState.value), + cartId: this.configuratorUtils.getCartId(cartState.value), + productCode: productCode, + configuration: configuration, + quantity: 1, + }; + this.store.dispatch( + new ConfiguratorTextfieldActions.AddToCart(addToCartParameters) + ); + }); + } + + /** + * Updates a cart entry, specified by its cart entry number. + * + * @param cartEntryNumber - Cart entry number + * @param configuration Textfield configuration (list of alphanumeric attributes) + */ + updateCartEntry( + cartEntryNumber: string, + configuration: ConfiguratorTextfield.Configuration + ): void { + this.activeCartService + .requireLoadedCart() + .pipe(take(1)) + .subscribe((cartState) => { + const updateCartParameters: ConfiguratorTextfield.UpdateCartEntryParameters = { + userId: this.configuratorUtils.getUserId(cartState.value), + cartId: this.configuratorUtils.getCartId(cartState.value), + cartEntryNumber: cartEntryNumber, + configuration: configuration, + }; + this.store.dispatch( + new ConfiguratorTextfieldActions.UpdateCartEntryConfiguration( + updateCartParameters + ) + ); + }); + } + + /** + * Returns a textfield configuration for a cart entry. + * + * @param owner - Configuration owner + * + * @returns {Observable} + */ + readConfigurationForCartEntry( + owner: CommonConfigurator.Owner + ): Observable { + return this.activeCartService.requireLoadedCart().pipe( + map((cartState) => ({ + userId: this.configuratorUtils.getUserId(cartState.value), + cartId: this.configuratorUtils.getCartId(cartState.value), + cartEntryNumber: owner.id, + owner: owner, + })), + tap((readFromCartEntryParameters) => + this.store.dispatch( + new ConfiguratorTextfieldActions.ReadCartEntryConfiguration( + readFromCartEntryParameters + ) + ) + ), + switchMapTo( + this.store.pipe( + select(ConfiguratorTextFieldSelectors.getConfigurationContent) + ) + ), + filter((configuration) => !this.isConfigurationInitial(configuration)) + ); + } + + /** + * Creates a textfield configuration supposed to be sent to the backend when an attribute + * has been changed + * @param changedAttribute Attribute changed by the end user + * @param oldConfiguration Existing configuration to which the attribute change is applied to + * @returns Textfield configuration (merge of existing configuration and the changed attribute) + */ + createNewConfigurationWithChange( + changedAttribute: ConfiguratorTextfield.ConfigurationInfo, + oldConfiguration: ConfiguratorTextfield.Configuration + ): ConfiguratorTextfield.Configuration { + const newConfiguration: ConfiguratorTextfield.Configuration = { + configurationInfos: [], + owner: oldConfiguration.owner, + }; + oldConfiguration.configurationInfos.forEach((info) => { + if (info.configurationLabel === changedAttribute.configurationLabel) { + changedAttribute.status = + ConfiguratorTextfield.ConfigurationStatus.SUCCESS; + newConfiguration.configurationInfos.push(changedAttribute); + } else { + newConfiguration.configurationInfos.push(info); + } + }); + return newConfiguration; + } + + protected isConfigurationInitial( + configuration: ConfiguratorTextfield.Configuration + ): boolean { + return configuration?.owner?.type === undefined; + } +} diff --git a/feature-libs/product-configurator/textfield/core/facade/index.ts b/feature-libs/product-configurator/textfield/core/facade/index.ts new file mode 100644 index 00000000000..3660e5e0759 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/facade/index.ts @@ -0,0 +1 @@ +export * from './configurator-textfield.service'; diff --git a/feature-libs/product/configurators/textfield/src/core/index.ts b/feature-libs/product-configurator/textfield/core/index.ts similarity index 100% rename from feature-libs/product/configurators/textfield/src/core/index.ts rename to feature-libs/product-configurator/textfield/core/index.ts diff --git a/feature-libs/product-configurator/textfield/core/model/configurator-textfield.model.ts b/feature-libs/product-configurator/textfield/core/model/configurator-textfield.model.ts new file mode 100644 index 00000000000..6a304002b8e --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/model/configurator-textfield.model.ts @@ -0,0 +1,47 @@ +import { CommonConfigurator } from '@spartacus/product-configurator/common'; + +export namespace ConfiguratorTextfield { + /** + * Textfield configuration. Consists of a list of attributes and the configuration owner + */ + export interface Configuration { + configurationInfos: ConfigurationInfo[]; + owner?: CommonConfigurator.Owner; + } + /** + * Represents a textfield configuration attribute. Carries a label, an alphanumeric value and a status + */ + export interface ConfigurationInfo { + configurationLabel?: string; + configurationValue?: string; + status?: ConfigurationStatus; + } + /** + * Textfield configuration status + */ + export enum ConfigurationStatus { + SUCCESS = 'SUCCESS', + ERROR = 'ERROR', + } + + /** + * Collection of parameters needed to add a textfield product to the cart + */ + export interface AddToCartParameters { + userId: string; + cartId: string; + productCode: string; + quantity: number; + configuration?: Configuration; + } + /** + * Collection of parameters needed to update the configuration that is attached + * to a cart entry + */ + export interface UpdateCartEntryParameters { + userId: string; + cartId: string; + cartEntryNumber: string; + configuration?: Configuration; + } +} diff --git a/feature-libs/product-configurator/textfield/core/state/actions/configurator-textfield-group.actions.ts b/feature-libs/product-configurator/textfield/core/state/actions/configurator-textfield-group.actions.ts new file mode 100644 index 00000000000..60c013d293e --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/actions/configurator-textfield-group.actions.ts @@ -0,0 +1 @@ +export * from './configurator-textfield.action'; diff --git a/feature-libs/product-configurator/textfield/core/state/actions/configurator-textfield.action.spec.ts b/feature-libs/product-configurator/textfield/core/state/actions/configurator-textfield.action.spec.ts new file mode 100644 index 00000000000..9e3dffa9491 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/actions/configurator-textfield.action.spec.ts @@ -0,0 +1,48 @@ +import * as ConfiguratorTextfieldActions from './configurator-textfield.action'; + +describe('ConfiguratorTextfieldActions', () => { + it('should provide create action with proper type', () => { + const createAction: ConfiguratorTextfieldActions.ConfiguratorActions = new ConfiguratorTextfieldActions.CreateConfiguration( + { productCode: undefined, owner: undefined } + ); + expect(createAction.type).toBe( + ConfiguratorTextfieldActions.CREATE_CONFIGURATION + ); + }); + + it('should provide create success action with proper type', () => { + const createAction: ConfiguratorTextfieldActions.ConfiguratorActions = new ConfiguratorTextfieldActions.CreateConfigurationSuccess( + { configurationInfos: [] } + ); + expect(createAction.type).toBe( + ConfiguratorTextfieldActions.CREATE_CONFIGURATION_SUCCESS + ); + }); + + it('should provide create fail action with proper type', () => { + const createAction: ConfiguratorTextfieldActions.ConfiguratorActions = new ConfiguratorTextfieldActions.CreateConfigurationFail( + {} + ); + expect(createAction.type).toBe( + ConfiguratorTextfieldActions.CREATE_CONFIGURATION_FAIL + ); + }); + + it('should provide update action with proper type', () => { + const updateAction: ConfiguratorTextfieldActions.ConfiguratorActions = new ConfiguratorTextfieldActions.UpdateConfiguration( + { configurationInfos: [] } + ); + expect(updateAction.type).toBe( + ConfiguratorTextfieldActions.UPDATE_CONFIGURATION + ); + }); + + it('should provide create action that carries productCode as a payload', () => { + const productCode = 'CONF_LAPTOP'; + const createAction = new ConfiguratorTextfieldActions.CreateConfiguration({ + productCode: productCode, + owner: undefined, + }); + expect(createAction.payload.productCode).toBe(productCode); + }); +}); diff --git a/feature-libs/product-configurator/textfield/core/state/actions/configurator-textfield.action.ts b/feature-libs/product-configurator/textfield/core/state/actions/configurator-textfield.action.ts new file mode 100644 index 00000000000..8baf22e5a99 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/actions/configurator-textfield.action.ts @@ -0,0 +1,127 @@ +import { StateUtils } from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { ConfiguratorTextfield } from '../../model/configurator-textfield.model'; +import { CONFIGURATION_TEXTFIELD_DATA } from '../configuration-textfield-state'; + +export const CREATE_CONFIGURATION = + '[Configurator] Create Configuration Textfield'; +export const CREATE_CONFIGURATION_FAIL = + '[Configurator] Create Configuration Textfield Fail'; +export const CREATE_CONFIGURATION_SUCCESS = + '[Configurator] Create Configuration Textfield Success'; +export const UPDATE_CONFIGURATION = + '[Configurator] Update Configuration Textfield'; +export const ADD_TO_CART = '[Configurator] Add to cart Textfield'; +export const ADD_TO_CART_FAIL = '[Configurator] Add to cart Textfield Fail'; +export const READ_CART_ENTRY_CONFIGURATION = + '[Configurator] Read cart entry configuration Textfield'; +export const READ_CART_ENTRY_CONFIGURATION_FAIL = + '[Configurator] Read cart entry configuration Textfield Fail'; +export const READ_CART_ENTRY_CONFIGURATION_SUCCESS = + '[Configurator] Read cart entry configuration Textfield Success'; +export const UPDATE_CART_ENTRY_CONFIGURATION = + '[Configurator] Update cart entry configuration Textfield'; +export const UPDATE_CART_ENTRY_CONFIGURATION_FAIL = + '[Configurator] Update cart entry configuration Textfield Fail'; + +export const REMOVE_CONFIGURATION = + '[Configurator] Remove Configuration Textfield'; + +export class CreateConfiguration extends StateUtils.LoaderLoadAction { + readonly type = CREATE_CONFIGURATION; + constructor( + public payload: { productCode: string; owner: CommonConfigurator.Owner } + ) { + super(CONFIGURATION_TEXTFIELD_DATA); + } +} + +export class CreateConfigurationFail extends StateUtils.LoaderFailAction { + readonly type = CREATE_CONFIGURATION_FAIL; + constructor(public payload: any) { + super(CONFIGURATION_TEXTFIELD_DATA, payload); + } +} + +export class CreateConfigurationSuccess extends StateUtils.LoaderSuccessAction { + readonly type = CREATE_CONFIGURATION_SUCCESS; + constructor(public payload: ConfiguratorTextfield.Configuration) { + super(CONFIGURATION_TEXTFIELD_DATA); + } +} + +export class UpdateConfiguration extends StateUtils.LoaderLoadAction { + readonly type = UPDATE_CONFIGURATION; + constructor(public payload: ConfiguratorTextfield.Configuration) { + super(CONFIGURATION_TEXTFIELD_DATA); + } +} + +export class AddToCart extends StateUtils.LoaderLoadAction { + readonly type = ADD_TO_CART; + constructor(public payload: ConfiguratorTextfield.AddToCartParameters) { + super(CONFIGURATION_TEXTFIELD_DATA); + } +} + +export class AddToCartFail extends StateUtils.LoaderFailAction { + readonly type = ADD_TO_CART_FAIL; + constructor(public payload: any) { + super(CONFIGURATION_TEXTFIELD_DATA, payload); + } +} + +export class UpdateCartEntryConfiguration extends StateUtils.LoaderLoadAction { + readonly type = UPDATE_CART_ENTRY_CONFIGURATION; + constructor(public payload: ConfiguratorTextfield.UpdateCartEntryParameters) { + super(CONFIGURATION_TEXTFIELD_DATA); + } +} + +export class UpdateCartEntryConfigurationFail extends StateUtils.LoaderFailAction { + readonly type = UPDATE_CART_ENTRY_CONFIGURATION_FAIL; + constructor(public payload: any) { + super(CONFIGURATION_TEXTFIELD_DATA, payload); + } +} + +export class ReadCartEntryConfiguration extends StateUtils.LoaderLoadAction { + readonly type = READ_CART_ENTRY_CONFIGURATION; + constructor( + public payload: CommonConfigurator.ReadConfigurationFromCartEntryParameters + ) { + super(CONFIGURATION_TEXTFIELD_DATA); + } +} + +export class ReadCartEntryConfigurationSuccess extends StateUtils.LoaderSuccessAction { + readonly type = READ_CART_ENTRY_CONFIGURATION_SUCCESS; + constructor(public payload: ConfiguratorTextfield.Configuration) { + super(CONFIGURATION_TEXTFIELD_DATA); + } +} + +export class ReadCartEntryConfigurationFail extends StateUtils.LoaderFailAction { + readonly type = READ_CART_ENTRY_CONFIGURATION_FAIL; + constructor(public payload: any) { + super(CONFIGURATION_TEXTFIELD_DATA, payload); + } +} + +export class RemoveConfiguration extends StateUtils.LoaderResetAction { + readonly type = REMOVE_CONFIGURATION; + constructor() { + super(CONFIGURATION_TEXTFIELD_DATA); + } +} + +export type ConfiguratorActions = + | CreateConfiguration + | CreateConfigurationFail + | CreateConfigurationSuccess + | UpdateConfiguration + | ReadCartEntryConfigurationFail + | ReadCartEntryConfigurationSuccess + | ReadCartEntryConfiguration + | UpdateCartEntryConfiguration + | RemoveConfiguration; diff --git a/feature-libs/product-configurator/textfield/core/state/actions/index.ts b/feature-libs/product-configurator/textfield/core/state/actions/index.ts new file mode 100644 index 00000000000..6fcd2fd41a4 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/actions/index.ts @@ -0,0 +1,2 @@ +import * as ConfiguratorTextfieldActions from './configurator-textfield-group.actions'; +export { ConfiguratorTextfieldActions }; diff --git a/feature-libs/product-configurator/textfield/core/state/configuration-textfield-state.ts b/feature-libs/product-configurator/textfield/core/state/configuration-textfield-state.ts new file mode 100644 index 00000000000..dfc94b7fbb0 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/configuration-textfield-state.ts @@ -0,0 +1,14 @@ +import { StateUtils } from '@spartacus/core'; +import { ConfiguratorTextfield } from '../model/configurator-textfield.model'; + +export const CONFIGURATION_TEXTFIELD_FEATURE = 'productConfigurationTextfield'; +export const CONFIGURATION_TEXTFIELD_DATA = + '[ConfiguratorTextfield] Configuration Data'; + +export interface StateWithConfigurationTextfield { + [CONFIGURATION_TEXTFIELD_FEATURE]: ConfigurationTextfieldState; +} + +export interface ConfigurationTextfieldState { + loaderState: StateUtils.LoaderState; +} diff --git a/feature-libs/product-configurator/textfield/core/state/configurator-textfield-store.module.ts b/feature-libs/product-configurator/textfield/core/state/configurator-textfield-store.module.ts new file mode 100644 index 00000000000..1854018f44b --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/configurator-textfield-store.module.ts @@ -0,0 +1,26 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; +import { StateModule } from '@spartacus/core'; +import { CONFIGURATION_TEXTFIELD_FEATURE } from './configuration-textfield-state'; +import { configuratorTextfieldEffects } from './effects/index'; +import { + configuratorTextfieldReducerProvider, + configuratorTextfieldReducerToken, +} from './reducers/index'; + +@NgModule({ + imports: [ + CommonModule, + + StateModule, + StoreModule.forFeature( + CONFIGURATION_TEXTFIELD_FEATURE, + configuratorTextfieldReducerToken + ), + EffectsModule.forFeature(configuratorTextfieldEffects), + ], + providers: [configuratorTextfieldReducerProvider], +}) +export class ConfiguratorTextfieldStoreModule {} diff --git a/feature-libs/product-configurator/textfield/core/state/effects/configurator-textfield.effect.spec.ts b/feature-libs/product-configurator/textfield/core/state/effects/configurator-textfield.effect.spec.ts new file mode 100644 index 00000000000..6b5b9f4c4fc --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/effects/configurator-textfield.effect.spec.ts @@ -0,0 +1,274 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { StoreModule } from '@ngrx/store'; +import { + CartActions, + CartModification, + normalizeHttpError, +} from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { cold, hot } from 'jasmine-marbles'; +import { Observable, of, throwError } from 'rxjs'; +import { ConfiguratorTextfieldConnector } from '../../connectors/configurator-textfield.connector'; +import { ConfiguratorTextfield } from '../../model/configurator-textfield.model'; +import { ConfiguratorTextfieldActions } from '../actions/index'; +import { CONFIGURATION_TEXTFIELD_FEATURE } from '../configuration-textfield-state'; +import * as reducers from '../reducers/index'; +import * as fromEffects from './configurator-textfield.effect'; + +const productCode = 'CONF_LAPTOP'; +const cartId = 'CART-1234'; +const cartEntryNumber = '1'; +const userId = 'theUser'; +const quantity = 1; + +const productConfiguration: ConfiguratorTextfield.Configuration = { + configurationInfos: [], +}; +const errorResponse: HttpErrorResponse = new HttpErrorResponse({ + error: 'notFound', + status: 404, +}); +const cartModification: CartModification = { + quantity: 1, + quantityAdded: 1, + deliveryModeChanged: true, + entry: { product: { code: productCode }, quantity: 1 }, + statusCode: '', + statusMessage: '', +}; + +describe('ConfiguratorTextfieldEffect', () => { + let createMock: jasmine.Spy; + let readFromCartEntryMock: jasmine.Spy; + let addToCartMock: jasmine.Spy; + let updateCartEntryMock: jasmine.Spy; + + let configEffects: fromEffects.ConfiguratorTextfieldEffects; + + let actions$: Observable; + + beforeEach(() => { + createMock = jasmine.createSpy().and.returnValue(of(productConfiguration)); + readFromCartEntryMock = jasmine + .createSpy() + .and.returnValue(of(productConfiguration)); + addToCartMock = jasmine.createSpy().and.returnValue(of(cartModification)); + updateCartEntryMock = jasmine + .createSpy() + .and.returnValue(of(cartModification)); + class MockConnector { + createConfiguration = createMock; + addToCart = addToCartMock; + readConfigurationForCartEntry = readFromCartEntryMock; + updateConfigurationForCartEntry = updateCartEntryMock; + } + + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + StoreModule.forRoot({}), + StoreModule.forFeature( + CONFIGURATION_TEXTFIELD_FEATURE, + reducers.getConfiguratorTextfieldReducers() + ), + ], + + providers: [ + fromEffects.ConfiguratorTextfieldEffects, + provideMockActions(() => actions$), + { + provide: ConfiguratorTextfieldConnector, + useClass: MockConnector, + }, + ], + }); + + configEffects = TestBed.inject( + fromEffects.ConfiguratorTextfieldEffects as Type< + fromEffects.ConfiguratorTextfieldEffects + > + ); + }); + + it('should provide configuration effects', () => { + expect(configEffects).toBeTruthy(); + }); + + it('should emit a success action with content for an action of type createConfiguration', () => { + const payloadInput = { productCode: productCode, owner: undefined }; + const action = new ConfiguratorTextfieldActions.CreateConfiguration( + payloadInput + ); + + const completion = new ConfiguratorTextfieldActions.CreateConfigurationSuccess( + productConfiguration + ); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: completion }); + + expect(configEffects.createConfiguration$).toBeObservable(expected); + }); + + it('should emit a fail action in case something goes wrong', () => { + createMock.and.returnValue(throwError(errorResponse)); + const payloadInput = { productCode: productCode, owner: undefined }; + const action = new ConfiguratorTextfieldActions.CreateConfiguration( + payloadInput + ); + + const completionFailure = new ConfiguratorTextfieldActions.CreateConfigurationFail( + normalizeHttpError(errorResponse) + ); + actions$ = hot('-a', { a: action }); + const expected = cold('-b', { b: completionFailure }); + + expect(configEffects.createConfiguration$).toBeObservable(expected); + }); + + it('should emit a success action with content for an action of type readConfigurationFromCart if read from cart is successful', () => { + const payloadInput: CommonConfigurator.ReadConfigurationFromCartEntryParameters = {}; + const action = new ConfiguratorTextfieldActions.ReadCartEntryConfiguration( + payloadInput + ); + + const completion = new ConfiguratorTextfieldActions.ReadCartEntryConfigurationSuccess( + productConfiguration + ); + actions$ = hot('-a', { a: action }); + const expectedObs = cold('-b', { b: completion }); + + expect(configEffects.readConfigurationForCartEntry$).toBeObservable( + expectedObs + ); + }); + + it('should emit a fail action in case read from cart leads to an error', () => { + readFromCartEntryMock.and.returnValue(throwError(errorResponse)); + const payloadInput: CommonConfigurator.ReadConfigurationFromCartEntryParameters = {}; + const action = new ConfiguratorTextfieldActions.ReadCartEntryConfiguration( + payloadInput + ); + + const completionFailure = new ConfiguratorTextfieldActions.ReadCartEntryConfigurationFail( + normalizeHttpError(errorResponse) + ); + actions$ = hot('-a', { a: action }); + const expectedObs = cold('-b', { b: completionFailure }); + + expect(configEffects.readConfigurationForCartEntry$).toBeObservable( + expectedObs + ); + }); + + it('createConfiguration must not emit anything in case source action is not covered', () => { + const action = new ConfiguratorTextfieldActions.CreateConfigurationSuccess({ + configurationInfos: [], + }); + actions$ = hot('-a', { a: action }); + const expectedObs = cold('-'); + + expect(configEffects.createConfiguration$).toBeObservable(expectedObs); + }); + + describe('Textfield Effect addToCart', () => { + it('should emit success on addToCart', () => { + const payloadInput = { + userId: userId, + cartId: cartId, + productCode: productCode, + quantity: quantity, + configuration: productConfiguration, + }; + const action = new ConfiguratorTextfieldActions.AddToCart(payloadInput); + const loadCart = new CartActions.LoadCart({ + cartId: cartId, + userId: userId, + }); + + const removeConfiguration = new ConfiguratorTextfieldActions.RemoveConfiguration(); + + actions$ = hot('-a', { a: action }); + const expected = cold('-(bc)', { + b: removeConfiguration, + c: loadCart, + }); + expect(configEffects.addToCart$).toBeObservable(expected); + }); + + it('should emit AddToCartFail in case add to cart call is not successful', () => { + addToCartMock.and.returnValue(throwError(errorResponse)); + const payloadInput = { + userId: userId, + cartId: cartId, + productCode: productCode, + quantity: quantity, + configuration: productConfiguration, + }; + const action = new ConfiguratorTextfieldActions.AddToCart(payloadInput); + const cartAddEntryFail = new ConfiguratorTextfieldActions.AddToCartFail( + normalizeHttpError(errorResponse) + ); + + actions$ = hot('-a', { a: action }); + + const expected = cold('-b', { + b: cartAddEntryFail, + }); + expect(configEffects.addToCart$).toBeObservable(expected); + }); + }); + + describe('Effect updateCartEntry', () => { + it('should emit CartUpdateEntrySuccess on updateCartEntry', () => { + const payloadInput: ConfiguratorTextfield.UpdateCartEntryParameters = { + userId: userId, + cartId: cartId, + cartEntryNumber: cartEntryNumber, + configuration: productConfiguration, + }; + const action = new ConfiguratorTextfieldActions.UpdateCartEntryConfiguration( + payloadInput + ); + const loadCart = new CartActions.LoadCart({ + userId: userId, + cartId: cartId, + }); + + const removeConfiguration = new ConfiguratorTextfieldActions.RemoveConfiguration(); + + actions$ = hot('-a', { a: action }); + const expected = cold('-(bc)', { + b: removeConfiguration, + c: loadCart, + }); + expect(configEffects.updateCartEntry$).toBeObservable(expected); + }); + + it('should emit CartUpdateEntryFail in case update cart entry is not successful', () => { + updateCartEntryMock.and.returnValue(throwError(errorResponse)); + const payloadInput: ConfiguratorTextfield.UpdateCartEntryParameters = { + userId: userId, + cartId: cartId, + cartEntryNumber: cartEntryNumber, + configuration: productConfiguration, + }; + const action = new ConfiguratorTextfieldActions.UpdateCartEntryConfiguration( + payloadInput + ); + const cartUpdateFail = new ConfiguratorTextfieldActions.UpdateCartEntryConfigurationFail( + normalizeHttpError(errorResponse) + ); + + actions$ = hot('-a', { a: action }); + + const expected = cold('-b', { + b: cartUpdateFail, + }); + expect(configEffects.updateCartEntry$).toBeObservable(expected); + }); + }); +}); diff --git a/feature-libs/product-configurator/textfield/core/state/effects/configurator-textfield.effect.ts b/feature-libs/product-configurator/textfield/core/state/effects/configurator-textfield.effect.ts new file mode 100644 index 00000000000..193eb160abf --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/effects/configurator-textfield.effect.ts @@ -0,0 +1,142 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { CartActions, normalizeHttpError } from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { ConfiguratorTextfieldConnector } from '../../connectors/configurator-textfield.connector'; +import { ConfiguratorTextfield } from '../../model/configurator-textfield.model'; +import { ConfiguratorTextfieldActions } from '../actions/index'; +@Injectable() +export class ConfiguratorTextfieldEffects { + @Effect() + createConfiguration$: Observable< + | ConfiguratorTextfieldActions.CreateConfigurationSuccess + | ConfiguratorTextfieldActions.CreateConfigurationFail + > = this.actions$.pipe( + ofType(ConfiguratorTextfieldActions.CREATE_CONFIGURATION), + map( + (action: ConfiguratorTextfieldActions.CreateConfiguration) => + action.payload + ), + switchMap((payload) => { + return this.configuratorTextfieldConnector + .createConfiguration(payload.productCode, payload.owner) + .pipe( + switchMap((configuration: ConfiguratorTextfield.Configuration) => { + return [ + new ConfiguratorTextfieldActions.CreateConfigurationSuccess( + configuration + ), + ]; + }), + catchError((error) => + of( + new ConfiguratorTextfieldActions.CreateConfigurationFail( + normalizeHttpError(error) + ) + ) + ) + ); + }) + ); + + @Effect() + addToCart$: Observable< + | ConfiguratorTextfieldActions.RemoveConfiguration + | ConfiguratorTextfieldActions.AddToCartFail + | CartActions.LoadCart + > = this.actions$.pipe( + ofType(ConfiguratorTextfieldActions.ADD_TO_CART), + map((action: ConfiguratorTextfieldActions.AddToCart) => action.payload), + switchMap((payload) => { + return this.configuratorTextfieldConnector.addToCart(payload).pipe( + switchMap(() => { + return [ + new ConfiguratorTextfieldActions.RemoveConfiguration(), + new CartActions.LoadCart({ + cartId: payload.cartId, + userId: payload.userId, + }), + ]; + }), + catchError((error) => + of( + new ConfiguratorTextfieldActions.AddToCartFail( + normalizeHttpError(error) + ) + ) + ) + ); + }) + ); + + @Effect() + updateCartEntry$: Observable< + | ConfiguratorTextfieldActions.RemoveConfiguration + | ConfiguratorTextfieldActions.UpdateCartEntryConfigurationFail + | CartActions.LoadCart + > = this.actions$.pipe( + ofType(ConfiguratorTextfieldActions.UPDATE_CART_ENTRY_CONFIGURATION), + map( + (action: ConfiguratorTextfieldActions.UpdateCartEntryConfiguration) => + action.payload + ), + switchMap((payload) => { + return this.configuratorTextfieldConnector + .updateConfigurationForCartEntry(payload) + .pipe( + switchMap(() => { + return [ + new ConfiguratorTextfieldActions.RemoveConfiguration(), + new CartActions.LoadCart({ + cartId: payload.cartId, + userId: payload.userId, + }), + ]; + }), + catchError((error) => + of( + new ConfiguratorTextfieldActions.UpdateCartEntryConfigurationFail( + normalizeHttpError(error) + ) + ) + ) + ); + }) + ); + + @Effect() + readConfigurationForCartEntry$: Observable< + | ConfiguratorTextfieldActions.ReadCartEntryConfigurationSuccess + | ConfiguratorTextfieldActions.ReadCartEntryConfigurationFail + > = this.actions$.pipe( + ofType(ConfiguratorTextfieldActions.READ_CART_ENTRY_CONFIGURATION), + switchMap( + (action: ConfiguratorTextfieldActions.ReadCartEntryConfiguration) => { + const parameters: CommonConfigurator.ReadConfigurationFromCartEntryParameters = + action.payload; + + return this.configuratorTextfieldConnector + .readConfigurationForCartEntry(parameters) + .pipe( + switchMap((result: ConfiguratorTextfield.Configuration) => [ + new ConfiguratorTextfieldActions.ReadCartEntryConfigurationSuccess( + result + ), + ]), + catchError((error) => [ + new ConfiguratorTextfieldActions.ReadCartEntryConfigurationFail( + normalizeHttpError(error) + ), + ]) + ); + } + ) + ); + + constructor( + private actions$: Actions, + private configuratorTextfieldConnector: ConfiguratorTextfieldConnector + ) {} +} diff --git a/feature-libs/product-configurator/textfield/core/state/effects/index.ts b/feature-libs/product-configurator/textfield/core/state/effects/index.ts new file mode 100644 index 00000000000..621e82aeb4a --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/effects/index.ts @@ -0,0 +1,7 @@ +import { ConfiguratorTextfieldEffects } from './configurator-textfield.effect'; + +export const configuratorTextfieldEffects: any[] = [ + ConfiguratorTextfieldEffects, +]; + +export * from './configurator-textfield.effect'; diff --git a/feature-libs/product-configurator/textfield/core/state/index.ts b/feature-libs/product-configurator/textfield/core/state/index.ts new file mode 100644 index 00000000000..6aca6d1eaf1 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/index.ts @@ -0,0 +1,3 @@ +export * from './actions/index'; +export * from './configuration-textfield-state'; +export * from './selectors/index'; diff --git a/feature-libs/product-configurator/textfield/core/state/reducers/configurator-textfield.reducer.spec.ts b/feature-libs/product-configurator/textfield/core/state/reducers/configurator-textfield.reducer.spec.ts new file mode 100644 index 00000000000..09e3a72a3ea --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/reducers/configurator-textfield.reducer.spec.ts @@ -0,0 +1,69 @@ +import { ConfiguratorTextfield } from '../../model/configurator-textfield.model'; +import { ConfiguratorTextfieldActions } from '../actions/index'; +import { reducer } from './configurator-textfield.reducer'; + +describe('ConfiguratorTextfieldReducer', () => { + const attributeName = 'attributeName'; + + const productConfigurationInitial: ConfiguratorTextfield.Configuration = { + configurationInfos: [], + owner: {}, + }; + const productConfiguration: ConfiguratorTextfield.Configuration = { + configurationInfos: [{ configurationLabel: attributeName }], + owner: {}, + }; + const productCode = 'CONF_LAPTOP'; + + it('should not change state in case action is not covered in reducer', () => { + const result = reducer( + productConfigurationInitial, + new ConfiguratorTextfieldActions.CreateConfiguration({ + productCode: productCode, + owner: undefined, + }) + ); + expect(result).toBeDefined(); + expect(result).toBe(productConfigurationInitial); + }); + + it('should change state on CreateConfigurationSuccess ', () => { + const result = reducer( + productConfigurationInitial, + new ConfiguratorTextfieldActions.CreateConfigurationSuccess( + productConfiguration + ) + ); + expect(result).toBeDefined(); + expect(result).toEqual(productConfiguration); + }); + + it('should change state on readFromCartEntry ', () => { + const result = reducer( + productConfigurationInitial, + new ConfiguratorTextfieldActions.ReadCartEntryConfigurationSuccess( + productConfiguration + ) + ); + expect(result).toBeDefined(); + expect(result).toEqual(productConfiguration); + }); + + it('should change state on UpdateConfiguration ', () => { + const result = reducer( + productConfigurationInitial, + new ConfiguratorTextfieldActions.UpdateConfiguration(productConfiguration) + ); + expect(result).toBeDefined(); + expect(result).toEqual(productConfiguration); + }); + + it('should remove state on RemoveConfiguration ', () => { + const result = reducer( + productConfiguration, + new ConfiguratorTextfieldActions.RemoveConfiguration() + ); + expect(result).toBeDefined(); + expect(result).toEqual(productConfigurationInitial); + }); +}); diff --git a/feature-libs/product-configurator/textfield/core/state/reducers/configurator-textfield.reducer.ts b/feature-libs/product-configurator/textfield/core/state/reducers/configurator-textfield.reducer.ts new file mode 100644 index 00000000000..f01aa85eb14 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/reducers/configurator-textfield.reducer.ts @@ -0,0 +1,28 @@ +import { ConfiguratorTextfield } from '../../model/configurator-textfield.model'; +import { ConfiguratorActions } from '../actions/configurator-textfield.action'; +import { ConfiguratorTextfieldActions } from '../actions/index'; + +export const initialState: ConfiguratorTextfield.Configuration = { + configurationInfos: [], + owner: {}, +}; + +export function reducer( + state = initialState, + action: ConfiguratorActions +): ConfiguratorTextfield.Configuration { + switch (action.type) { + case ConfiguratorTextfieldActions.CREATE_CONFIGURATION_SUCCESS: + case ConfiguratorTextfieldActions.READ_CART_ENTRY_CONFIGURATION_SUCCESS: + case ConfiguratorTextfieldActions.UPDATE_CONFIGURATION: { + return { + ...state, + ...action.payload, + }; + } + case ConfiguratorTextfieldActions.REMOVE_CONFIGURATION: { + return initialState; + } + } + return state; +} diff --git a/feature-libs/product-configurator/textfield/core/state/reducers/index.ts b/feature-libs/product-configurator/textfield/core/state/reducers/index.ts new file mode 100644 index 00000000000..b3c8c6ba3e2 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/reducers/index.ts @@ -0,0 +1,31 @@ +import { InjectionToken, Provider } from '@angular/core'; +import { ActionReducerMap } from '@ngrx/store'; +import { StateUtils } from '@spartacus/core'; +import { ConfiguratorTextfield } from '../../model/configurator-textfield.model'; +import { + ConfigurationTextfieldState, + CONFIGURATION_TEXTFIELD_DATA, +} from '../configuration-textfield-state'; +import { reducer as configuratorTextfieldReducer } from './configurator-textfield.reducer'; + +export function getConfiguratorTextfieldReducers(): ActionReducerMap< + ConfigurationTextfieldState +> { + return { + loaderState: StateUtils.loaderReducer( + CONFIGURATION_TEXTFIELD_DATA, + configuratorTextfieldReducer + ), + }; +} + +export const configuratorTextfieldReducerToken: InjectionToken> = new InjectionToken>( + 'ConfiguratorReducers' +); + +export const configuratorTextfieldReducerProvider: Provider = { + provide: configuratorTextfieldReducerToken, + useFactory: getConfiguratorTextfieldReducers, +}; diff --git a/feature-libs/product-configurator/textfield/core/state/selectors/configurator-textfield-group.selectors.ts b/feature-libs/product-configurator/textfield/core/state/selectors/configurator-textfield-group.selectors.ts new file mode 100644 index 00000000000..3fa7a49d8c2 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/selectors/configurator-textfield-group.selectors.ts @@ -0,0 +1 @@ +export * from './configurator-textfield.selector'; diff --git a/feature-libs/product-configurator/textfield/core/state/selectors/configurator-textfield.selector.spec.ts b/feature-libs/product-configurator/textfield/core/state/selectors/configurator-textfield.selector.spec.ts new file mode 100644 index 00000000000..dba31f3395b --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/selectors/configurator-textfield.selector.spec.ts @@ -0,0 +1,67 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { select, Store, StoreModule } from '@ngrx/store'; +import { ConfiguratorTextfield } from '../../model/configurator-textfield.model'; +import * as ConfiguratorActions from '../actions/configurator-textfield.action'; +import { + CONFIGURATION_TEXTFIELD_FEATURE, + StateWithConfigurationTextfield, +} from '../configuration-textfield-state'; +import * as fromReducers from '../reducers/index'; +import { ConfiguratorTextFieldSelectors } from './index'; + +describe('ConfiguratorTextfieldSelectors', () => { + let store: Store; + const configuration: ConfiguratorTextfield.Configuration = { + configurationInfos: [ + { + configurationLabel: 'Colour', + configurationValue: 'Black', + status: ConfiguratorTextfield.ConfigurationStatus.SUCCESS, + }, + ], + owner: {}, + }; + const configurationInitial: ConfiguratorTextfield.Configuration = { + configurationInfos: [], + owner: {}, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature( + CONFIGURATION_TEXTFIELD_FEATURE, + fromReducers.getConfiguratorTextfieldReducers() + ), + ], + }); + + store = TestBed.inject( + Store as Type> + ); + }); + + it('should return empty content when selecting with content selector initially', () => { + let result: ConfiguratorTextfield.Configuration; + store + .pipe(select(ConfiguratorTextFieldSelectors.getConfigurationContent)) + .subscribe((value) => (result = value)); + + expect(result).toEqual(configurationInitial); + }); + + it('should return content from state when selecting with content selector', () => { + let result: ConfiguratorTextfield.Configuration; + store.dispatch( + new ConfiguratorActions.CreateConfigurationSuccess(configuration) + ); + + store + .pipe(select(ConfiguratorTextFieldSelectors.getConfigurationContent)) + .subscribe((value) => (result = value)); + + expect(result).toEqual(configuration); + }); +}); diff --git a/feature-libs/product-configurator/textfield/core/state/selectors/configurator-textfield.selector.ts b/feature-libs/product-configurator/textfield/core/state/selectors/configurator-textfield.selector.ts new file mode 100644 index 00000000000..31bfdff95ff --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/selectors/configurator-textfield.selector.ts @@ -0,0 +1,26 @@ +import { + createFeatureSelector, + createSelector, + MemoizedSelector, +} from '@ngrx/store'; +import { ConfiguratorTextfield } from '../../model/configurator-textfield.model'; +import { + ConfigurationTextfieldState, + CONFIGURATION_TEXTFIELD_FEATURE, + StateWithConfigurationTextfield, +} from '../configuration-textfield-state'; + +const getConfigurationContentSelector = (state: ConfigurationTextfieldState) => + state.loaderState.value; + +export const getConfigurationsState: MemoizedSelector< + StateWithConfigurationTextfield, + ConfigurationTextfieldState +> = createFeatureSelector( + CONFIGURATION_TEXTFIELD_FEATURE +); + +export const getConfigurationContent: MemoizedSelector< + StateWithConfigurationTextfield, + ConfiguratorTextfield.Configuration +> = createSelector(getConfigurationsState, getConfigurationContentSelector); diff --git a/feature-libs/product-configurator/textfield/core/state/selectors/index.ts b/feature-libs/product-configurator/textfield/core/state/selectors/index.ts new file mode 100644 index 00000000000..3cdacd2e657 --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/state/selectors/index.ts @@ -0,0 +1,2 @@ +import * as ConfiguratorTextFieldSelectors from './configurator-textfield-group.selectors'; +export { ConfiguratorTextFieldSelectors }; diff --git a/feature-libs/product-configurator/textfield/core/textfield-configurator-core.module.ts b/feature-libs/product-configurator/textfield/core/textfield-configurator-core.module.ts new file mode 100644 index 00000000000..9f39353865d --- /dev/null +++ b/feature-libs/product-configurator/textfield/core/textfield-configurator-core.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { ConfiguratorTextfieldConnector } from './connectors/configurator-textfield.connector'; +import { ConfiguratorTextfieldStoreModule } from './state/configurator-textfield-store.module'; + +/** + * Exposes the textfield configurator core entities. + * Explicit providing of connector because otherwise lazy loading does not work + */ +@NgModule({ + imports: [ConfiguratorTextfieldStoreModule], + providers: [ConfiguratorTextfieldConnector], +}) +export class TextfieldConfiguratorCoreModule {} diff --git a/feature-libs/product/configurators/textfield/src/index.ts b/feature-libs/product-configurator/textfield/index.ts similarity index 79% rename from feature-libs/product/configurators/textfield/src/index.ts rename to feature-libs/product-configurator/textfield/index.ts index 08bcc1ac436..21415419bb8 100644 --- a/feature-libs/product/configurators/textfield/src/index.ts +++ b/feature-libs/product-configurator/textfield/index.ts @@ -1,3 +1,4 @@ export * from './components/index'; export * from './core/index'; +export * from './occ/index'; export * from './textfield-configurator.module'; diff --git a/feature-libs/product-configurator/textfield/ng-package.json b/feature-libs/product-configurator/textfield/ng-package.json new file mode 100644 index 00000000000..9632f517c67 --- /dev/null +++ b/feature-libs/product-configurator/textfield/ng-package.json @@ -0,0 +1,14 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts", + "umdModuleIds": { + "@spartacus/core": "core", + "@spartacus/storefront": "storefront", + "@ng-select/ng-select": "ngSelect", + "@ngrx/effects": "effects", + "@ngrx/store": "store", + "rxjs": "rxjs" + } + } +} diff --git a/feature-libs/product-configurator/textfield/occ/converters/index.ts b/feature-libs/product-configurator/textfield/occ/converters/index.ts new file mode 100644 index 00000000000..9f7888f8ca9 --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/converters/index.ts @@ -0,0 +1,2 @@ +export * from './occ-configurator-textfield-add-to-cart-serializer'; +export * from './occ-configurator-textfield-normalizer'; diff --git a/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-add-to-cart-serializer.spec.ts b/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-add-to-cart-serializer.spec.ts new file mode 100644 index 00000000000..c3ee0c72374 --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-add-to-cart-serializer.spec.ts @@ -0,0 +1,64 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ConfiguratorTextfield } from '../../core/model/configurator-textfield.model'; +import { OccConfiguratorTextfieldAddToCartSerializer } from './occ-configurator-textfield-add-to-cart-serializer'; + +describe('OccConfiguratorTextfieldAddToCartSerializer', () => { + let occConfiguratorVariantAddToCartSerializer: OccConfiguratorTextfieldAddToCartSerializer; + + const USER_ID = 'theUser'; + const CART_ID = '98876'; + const PRODUCT_CODE = 'CPQ_LAPTOP'; + const QUANTITY = 1; + const LABEL1 = 'LABEL1'; + const VALUE1 = 'VALUE1'; + const TEXTFIELD = 'TEXTFIELD'; + + const configuration: ConfiguratorTextfield.Configuration = { + configurationInfos: [ + { + configurationLabel: LABEL1, + configurationValue: VALUE1, + status: ConfiguratorTextfield.ConfigurationStatus.SUCCESS, + }, + ], + }; + + const sourceParameters: ConfiguratorTextfield.AddToCartParameters = { + userId: USER_ID, + cartId: CART_ID, + productCode: PRODUCT_CODE, + quantity: QUANTITY, + configuration: configuration, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [OccConfiguratorTextfieldAddToCartSerializer], + }); + + occConfiguratorVariantAddToCartSerializer = TestBed.inject( + OccConfiguratorTextfieldAddToCartSerializer as Type< + OccConfiguratorTextfieldAddToCartSerializer + > + ); + }); + + it('should convert addToCart parameters to occAddToCartParameters', () => { + const convertedParameters = occConfiguratorVariantAddToCartSerializer.convert( + sourceParameters + ); + expect(convertedParameters.userId).toEqual(sourceParameters.userId); + expect(convertedParameters.product.code).toEqual( + sourceParameters.productCode + ); + expect(convertedParameters.configurationInfos[0].configuratorType).toEqual( + TEXTFIELD + ); + expect( + convertedParameters.configurationInfos[0].configurationLabel + ).toEqual( + sourceParameters.configuration.configurationInfos[0].configurationLabel + ); + }); +}); diff --git a/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-add-to-cart-serializer.ts b/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-add-to-cart-serializer.ts new file mode 100644 index 00000000000..c70c9768153 --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-add-to-cart-serializer.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { ConfiguratorTextfield } from '../../core/model/configurator-textfield.model'; +import { OccConfiguratorTextfield } from '../occ-configurator-textfield.models'; + +export const CONFIGURATOR_TYPE_TEXTFIELD = 'TEXTFIELD'; + +@Injectable({ providedIn: 'root' }) +export class OccConfiguratorTextfieldAddToCartSerializer + implements + Converter< + ConfiguratorTextfield.AddToCartParameters, + OccConfiguratorTextfield.AddToCartParameters + > { + constructor() {} + /** + * Converts addToCart parameters into the OCC format + * @param source Add to cart parameters in generic format + * @param target Add to cart parameters in OCC format. Optional, can be used in case converters should be chained + * @returns Add to cart parameters in OCC format + */ + convert( + source: ConfiguratorTextfield.AddToCartParameters, + target?: OccConfiguratorTextfield.AddToCartParameters + ): OccConfiguratorTextfield.AddToCartParameters { + const resultTarget: OccConfiguratorTextfield.AddToCartParameters = { + ...target, + userId: source.userId, + cartId: source.cartId, + product: { code: source.productCode }, + quantity: source.quantity, + configurationInfos: [], + }; + + source.configuration.configurationInfos.forEach((info) => + this.convertInfo(info, resultTarget.configurationInfos) + ); + + return resultTarget; + } + + protected convertInfo( + source: ConfiguratorTextfield.ConfigurationInfo, + occConfigurationInfos: OccConfiguratorTextfield.ConfigurationInfo[] + ): void { + const occInfo: OccConfiguratorTextfield.ConfigurationInfo = { + configurationLabel: source.configurationLabel, + configurationValue: source.configurationValue, + status: source.status, + configuratorType: CONFIGURATOR_TYPE_TEXTFIELD, + }; + occConfigurationInfos.push(occInfo); + } +} diff --git a/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-normalizer.spec.ts b/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-normalizer.spec.ts new file mode 100644 index 00000000000..34f7f0b1018 --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-normalizer.spec.ts @@ -0,0 +1,54 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ConverterService } from '@spartacus/core'; +import { ConfiguratorTextfield } from '../../core/model/configurator-textfield.model'; +import { OccConfiguratorTextfield } from '../occ-configurator-textfield.models'; +import { OccConfiguratorTextfieldNormalizer } from './occ-configurator-textfield-normalizer'; + +const csticName = 'name'; +const valueName = 'Black'; + +const configuration: OccConfiguratorTextfield.Configuration = { + configurationInfos: [ + { + configurationLabel: csticName, + configurationValue: valueName, + }, + ], +}; + +class MockConverterService { + convert() {} +} + +describe('OccConfiguratorTextfieldNormalizer', () => { + let occConfiguratorTextfieldNormalizer: OccConfiguratorTextfieldNormalizer; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OccConfiguratorTextfieldNormalizer, + { provide: ConverterService, useClass: MockConverterService }, + ], + }); + + occConfiguratorTextfieldNormalizer = TestBed.inject( + OccConfiguratorTextfieldNormalizer as Type< + OccConfiguratorTextfieldNormalizer + > + ); + }); + + it('should be created', () => { + expect(occConfiguratorTextfieldNormalizer).toBeTruthy(); + }); + + it('should convert a configuration', () => { + const result: ConfiguratorTextfield.Configuration = occConfiguratorTextfieldNormalizer.convert( + configuration + ); + expect(result.configurationInfos.length).toBe(1); + expect((result.configurationInfos[0].configurationLabel = csticName)); + expect((result.configurationInfos[0].configurationValue = valueName)); + }); +}); diff --git a/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-normalizer.ts b/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-normalizer.ts new file mode 100644 index 00000000000..a530d610ef1 --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-normalizer.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { ConfiguratorTextfield } from '../../core/model/configurator-textfield.model'; +import { OccConfiguratorTextfield } from '../occ-configurator-textfield.models'; + +@Injectable({ providedIn: 'root' }) +export class OccConfiguratorTextfieldNormalizer + implements + Converter< + OccConfiguratorTextfield.Configuration, + ConfiguratorTextfield.Configuration + > { + constructor() {} + /** + * Converts addToCart parameters into the generic format + * @param source Add to cart parameters in OCC format + * @param target Optional result, can be provided in case converters should be chained + * @returns Add to cart parameters in generic format + */ + convert( + source: OccConfiguratorTextfield.Configuration, + target?: ConfiguratorTextfield.Configuration + ): ConfiguratorTextfield.Configuration { + const resultTarget: ConfiguratorTextfield.Configuration = { + ...target, + ...(source as any), + }; + + return resultTarget; + } +} diff --git a/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-update-cart-entry-serializer.spec.ts b/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-update-cart-entry-serializer.spec.ts new file mode 100644 index 00000000000..790f6b64e0f --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-update-cart-entry-serializer.spec.ts @@ -0,0 +1,59 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ConfiguratorTextfield } from '../../core/model/configurator-textfield.model'; +import { OccConfiguratorTextfieldUpdateCartEntrySerializer } from './occ-configurator-textfield-update-cart-entry-serializer'; + +describe('OccConfiguratorTextfieldUpdateCartEntrySerializer', () => { + let occConfiguratorUpdateCartEntrySerializer: OccConfiguratorTextfieldUpdateCartEntrySerializer; + + const USER_ID = 'theUser'; + const CART_ID = '98876'; + const LABEL1 = 'LABEL1'; + const VALUE1 = 'VALUE1'; + const TEXTFIELD = 'TEXTFIELD'; + const CART_ENTRY_NUMBER = '2'; + + const configuration: ConfiguratorTextfield.Configuration = { + configurationInfos: [ + { + configurationLabel: LABEL1, + configurationValue: VALUE1, + status: ConfiguratorTextfield.ConfigurationStatus.SUCCESS, + }, + ], + }; + + const sourceParameters: ConfiguratorTextfield.UpdateCartEntryParameters = { + userId: USER_ID, + cartId: CART_ID, + cartEntryNumber: CART_ENTRY_NUMBER, + configuration: configuration, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [OccConfiguratorTextfieldUpdateCartEntrySerializer], + }); + + occConfiguratorUpdateCartEntrySerializer = TestBed.inject( + OccConfiguratorTextfieldUpdateCartEntrySerializer as Type< + OccConfiguratorTextfieldUpdateCartEntrySerializer + > + ); + }); + + it('should convert updateCartEntry parameters', () => { + const convertedParameters = occConfiguratorUpdateCartEntrySerializer.convert( + sourceParameters + ); + expect(convertedParameters.userId).toEqual(sourceParameters.userId); + expect(convertedParameters.configurationInfos[0].configuratorType).toEqual( + TEXTFIELD + ); + expect( + convertedParameters.configurationInfos[0].configurationLabel + ).toEqual( + sourceParameters.configuration.configurationInfos[0].configurationLabel + ); + }); +}); diff --git a/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-update-cart-entry-serializer.ts b/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-update-cart-entry-serializer.ts new file mode 100644 index 00000000000..f7e12403ec8 --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/converters/occ-configurator-textfield-update-cart-entry-serializer.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { ConfiguratorTextfield } from '../../core/model/configurator-textfield.model'; +import { OccConfiguratorTextfield } from '../occ-configurator-textfield.models'; + +export const CONFIGURATOR_TYPE_TEXTFIELD = 'TEXTFIELD'; + +@Injectable({ providedIn: 'root' }) +export class OccConfiguratorTextfieldUpdateCartEntrySerializer + implements + Converter< + ConfiguratorTextfield.UpdateCartEntryParameters, + OccConfiguratorTextfield.UpdateCartEntryParameters + > { + constructor() {} + + /** + * Converts the attributes for the updateCartEntry request into OCC format. Most attributes are just copied, + * except for the backend configurator type that needs to be set to 'TEXTFIELD' + * @param source Attributes for updating a cart entries' configuration in generic format + * @returns ttributes for updating a cart entries' configuration in OCC format + */ + convert( + source: ConfiguratorTextfield.UpdateCartEntryParameters + ): OccConfiguratorTextfield.UpdateCartEntryParameters { + const target: OccConfiguratorTextfield.UpdateCartEntryParameters = { + userId: source.userId, + cartId: source.cartId, + cartEntryNumber: source.cartEntryNumber, + configurationInfos: [], + }; + + source.configuration.configurationInfos.forEach((info) => + this.convertInfo(info, target.configurationInfos) + ); + + return target; + } + + protected convertInfo( + source: ConfiguratorTextfield.ConfigurationInfo, + occConfigurationInfos: OccConfiguratorTextfield.ConfigurationInfo[] + ): void { + const occInfo: OccConfiguratorTextfield.ConfigurationInfo = { + configurationLabel: source.configurationLabel, + configurationValue: source.configurationValue, + status: source.status, + configuratorType: CONFIGURATOR_TYPE_TEXTFIELD, + }; + occConfigurationInfos.push(occInfo); + } +} diff --git a/feature-libs/product-configurator/textfield/occ/default-occ-configurator-textfield-config.ts b/feature-libs/product-configurator/textfield/occ/default-occ-configurator-textfield-config.ts new file mode 100644 index 00000000000..92c051870e9 --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/default-occ-configurator-textfield-config.ts @@ -0,0 +1,23 @@ +import { OccConfig } from '@spartacus/core'; + +export function defaultOccConfiguratorTextfieldConfigFactory(): OccConfig { + return { + backend: { + occ: { + endpoints: { + createTextfieldConfiguration: + 'products/${productCode}/configurator/textfield', + + addTextfieldConfigurationToCart: + 'users/${userId}/carts/${cartId}/entries/configurator/textfield', + + readTextfieldConfigurationForCartEntry: + 'users/${userId}/carts/${cartId}/entries/${cartEntryNumber}/configurator/textfield', + + updateTextfieldConfigurationForCartEntry: + 'users/${userId}/carts/${cartId}/entries/${cartEntryNumber}/configurator/textfield', + }, + }, + }, + }; +} diff --git a/feature-libs/product-configurator/textfield/occ/index.ts b/feature-libs/product-configurator/textfield/occ/index.ts new file mode 100644 index 00000000000..8765af7b119 --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/index.ts @@ -0,0 +1,3 @@ +export * from './converters/index'; +export * from './occ-configurator-textfield.adapter'; +export * from './textfield-configurator-occ.module'; diff --git a/feature-libs/product-configurator/textfield/occ/occ-configurator-textfield.adapter.spec.ts b/feature-libs/product-configurator/textfield/occ/occ-configurator-textfield.adapter.spec.ts new file mode 100644 index 00000000000..07df82f4e58 --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/occ-configurator-textfield.adapter.spec.ts @@ -0,0 +1,199 @@ +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + CART_MODIFICATION_NORMALIZER, + ConverterService, + OccEndpointsService, +} from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { OccConfiguratorTextfieldAdapter } from '.'; +import { CONFIGURATION_TEXTFIELD_NORMALIZER } from '../core/connectors/converters'; +import { ConfiguratorTextfield } from '../core/model/configurator-textfield.model'; + +class MockOccEndpointsService { + getUrl(endpoint: string, _urlParams?: object, _queryParams?: object) { + return this.getEndpoint(endpoint); + } + getEndpoint(url: string) { + return url; + } +} +const productCode = 'CONF_LAPTOP'; +const USER_ID = 'theUser'; +const CART_ID = '98876'; +const CART_ENTRY_NUMBER = '1'; +const PRODUCT_CODE = 'CPQ_LAPTOP'; +const QUANTITY = 1; +const LABEL1 = 'LABEL1'; +const VALUE1 = 'VALUE1'; +const configuration: ConfiguratorTextfield.Configuration = { + configurationInfos: [ + { + configurationLabel: LABEL1, + configurationValue: VALUE1, + status: ConfiguratorTextfield.ConfigurationStatus.SUCCESS, + }, + ], +}; + +const addToCartParameters: ConfiguratorTextfield.AddToCartParameters = { + userId: USER_ID, + cartId: CART_ID, + productCode: PRODUCT_CODE, + quantity: QUANTITY, + configuration: configuration, +}; + +const updateCartEntryParameters: ConfiguratorTextfield.UpdateCartEntryParameters = { + userId: USER_ID, + cartId: CART_ID, + cartEntryNumber: CART_ENTRY_NUMBER, + configuration: configuration, +}; +const readParams: CommonConfigurator.ReadConfigurationFromCartEntryParameters = { + userId: USER_ID, + cartId: CART_ID, + cartEntryNumber: '0', +}; + +describe('OccConfigurationTextfieldAdapter', () => { + let occConfiguratorVariantAdapter: OccConfiguratorTextfieldAdapter; + let httpMock: HttpTestingController; + let converterService: ConverterService; + let occEnpointsService: OccEndpointsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + OccConfiguratorTextfieldAdapter, + { provide: OccEndpointsService, useClass: MockOccEndpointsService }, + ], + }); + + httpMock = TestBed.inject( + HttpTestingController as Type + ); + converterService = TestBed.inject( + ConverterService as Type + ); + occEnpointsService = TestBed.inject( + OccEndpointsService as Type + ); + + occConfiguratorVariantAdapter = TestBed.inject( + OccConfiguratorTextfieldAdapter as Type + ); + + spyOn(converterService, 'pipeable').and.callThrough(); + spyOn(converterService, 'convert').and.callThrough(); + spyOn(occEnpointsService, 'getUrl').and.callThrough(); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should call createTextfieldConfiguration endpoint', () => { + occConfiguratorVariantAdapter + .createConfiguration(productCode, null) + .subscribe(); + + const mockReq = httpMock.expectOne((req) => { + return req.method === 'GET' && req.url === 'createTextfieldConfiguration'; + }); + + expect(occEnpointsService.getUrl).toHaveBeenCalledWith( + 'createTextfieldConfiguration', + { + productCode, + } + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + CONFIGURATION_TEXTFIELD_NORMALIZER + ); + }); + + it('should call readTextfieldConfigurationForCartEntry endpoint', () => { + occConfiguratorVariantAdapter + .readConfigurationForCartEntry(readParams) + .subscribe(); + + const mockReq = httpMock.expectOne((req) => { + return ( + req.method === 'GET' && + req.url === 'readTextfieldConfigurationForCartEntry' + ); + }); + + expect(occEnpointsService.getUrl).toHaveBeenCalledWith( + 'readTextfieldConfigurationForCartEntry', + readParams + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + CONFIGURATION_TEXTFIELD_NORMALIZER + ); + }); + + it('should call addConfigurationTextfieldToCart endpoint', () => { + occConfiguratorVariantAdapter.addToCart(addToCartParameters).subscribe(); + + const mockReq = httpMock.expectOne((req) => { + return ( + req.method === 'POST' && req.url === 'addTextfieldConfigurationToCart' + ); + }); + + expect(occEnpointsService.getUrl).toHaveBeenCalledWith( + 'addTextfieldConfigurationToCart', + { + userId: USER_ID, + cartId: CART_ID, + } + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + CART_MODIFICATION_NORMALIZER + ); + }); + + it('should call correct endpoint when update cart entry is triggered', () => { + occConfiguratorVariantAdapter + .updateConfigurationForCartEntry(updateCartEntryParameters) + .subscribe(); + + const mockReq = httpMock.expectOne((req) => { + return ( + req.method === 'POST' && + req.url === 'updateTextfieldConfigurationForCartEntry' + ); + }); + + expect(occEnpointsService.getUrl).toHaveBeenCalledWith( + 'updateTextfieldConfigurationForCartEntry', + { + userId: USER_ID, + cartId: CART_ID, + cartEntryNumber: CART_ENTRY_NUMBER, + } + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(converterService.pipeable).toHaveBeenCalledWith( + CART_MODIFICATION_NORMALIZER + ); + }); +}); diff --git a/feature-libs/product-configurator/textfield/occ/occ-configurator-textfield.adapter.ts b/feature-libs/product-configurator/textfield/occ/occ-configurator-textfield.adapter.ts new file mode 100644 index 00000000000..7996a265ed9 --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/occ-configurator-textfield.adapter.ts @@ -0,0 +1,119 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { + CartModification, + CART_MODIFICATION_NORMALIZER, + ConverterService, + OccEndpointsService, +} from '@spartacus/core'; +import { CommonConfigurator } from '@spartacus/product-configurator/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ConfiguratorTextfieldAdapter } from '../core/connectors/configurator-textfield.adapter'; +import { + CONFIGURATION_TEXTFIELD_ADD_TO_CART_SERIALIZER, + CONFIGURATION_TEXTFIELD_NORMALIZER, + CONFIGURATION_TEXTFIELD_UPDATE_CART_ENTRY_SERIALIZER, +} from '../core/connectors/converters'; +import { ConfiguratorTextfield } from '../core/model/configurator-textfield.model'; +import { OccConfiguratorTextfield } from './occ-configurator-textfield.models'; + +@Injectable() +export class OccConfiguratorTextfieldAdapter + implements ConfiguratorTextfieldAdapter { + constructor( + protected http: HttpClient, + protected occEndpointsService: OccEndpointsService, + protected converterService: ConverterService + ) {} + + createConfiguration( + productCode: string, + owner: CommonConfigurator.Owner + ): Observable { + return this.http + .get( + this.occEndpointsService.getUrl('createTextfieldConfiguration', { + productCode, + }) + ) + .pipe( + this.converterService.pipeable(CONFIGURATION_TEXTFIELD_NORMALIZER), + map((resultConfiguration) => { + return { + ...resultConfiguration, + owner: { + ...owner, + }, + }; + }) + ); + } + + addToCart( + parameters: ConfiguratorTextfield.AddToCartParameters + ): Observable { + const url = this.occEndpointsService.getUrl( + 'addTextfieldConfigurationToCart', + { + userId: parameters.userId, + cartId: parameters.cartId, + } + ); + + const occAddToCartParameters = this.converterService.convert( + parameters, + CONFIGURATION_TEXTFIELD_ADD_TO_CART_SERIALIZER + ); + + return this.http + .post(url, occAddToCartParameters) + .pipe(this.converterService.pipeable(CART_MODIFICATION_NORMALIZER)); + } + + readConfigurationForCartEntry( + parameters: CommonConfigurator.ReadConfigurationFromCartEntryParameters + ): Observable { + const url = this.occEndpointsService.getUrl( + 'readTextfieldConfigurationForCartEntry', + { + userId: parameters.userId, + cartId: parameters.cartId, + cartEntryNumber: parameters.cartEntryNumber, + } + ); + + return this.http.get(url).pipe( + this.converterService.pipeable(CONFIGURATION_TEXTFIELD_NORMALIZER), + map((resultConfiguration) => { + return { + ...resultConfiguration, + owner: { + ...parameters.owner, + }, + }; + }) + ); + } + updateConfigurationForCartEntry( + parameters: ConfiguratorTextfield.UpdateCartEntryParameters + ): Observable { + const url = this.occEndpointsService.getUrl( + 'updateTextfieldConfigurationForCartEntry', + { + userId: parameters.userId, + cartId: parameters.cartId, + cartEntryNumber: parameters.cartEntryNumber, + } + ); + + const occUpdateCartEntryParameters = this.converterService.convert( + parameters, + CONFIGURATION_TEXTFIELD_UPDATE_CART_ENTRY_SERIALIZER + ); + + return this.http + .post(url, occUpdateCartEntryParameters) + .pipe(this.converterService.pipeable(CART_MODIFICATION_NORMALIZER)); + } +} diff --git a/feature-libs/product-configurator/textfield/occ/occ-configurator-textfield.models.ts b/feature-libs/product-configurator/textfield/occ/occ-configurator-textfield.models.ts new file mode 100644 index 00000000000..8c212a8f7cf --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/occ-configurator-textfield.models.ts @@ -0,0 +1,34 @@ +export namespace OccConfiguratorTextfield { + /** + * An interface representing the textfield configuration consumed through OCC. + */ + export interface Configuration { + configurationInfos: ConfigurationInfo[]; + } + + export interface ConfigurationInfo { + configurationLabel?: string; + configurationValue?: string; + status?: string; + configuratorType?: string; + } + + export interface AddToCartParameters { + userId?: string; + cartId?: string; + product?: AddToCartProductData; + quantity?: number; + configurationInfos?: ConfigurationInfo[]; + } + + export interface UpdateCartEntryParameters { + userId?: string; + cartId?: string; + cartEntryNumber?: string; + configurationInfos?: ConfigurationInfo[]; + } + + export interface AddToCartProductData { + code?: string; + } +} diff --git a/feature-libs/product-configurator/textfield/occ/textfield-configurator-occ.module.ts b/feature-libs/product-configurator/textfield/occ/textfield-configurator-occ.module.ts new file mode 100644 index 00000000000..f9b86182c90 --- /dev/null +++ b/feature-libs/product-configurator/textfield/occ/textfield-configurator-occ.module.ts @@ -0,0 +1,46 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { ConfigModule } from '@spartacus/core'; +import { ConfiguratorTextfieldAdapter } from '../core/connectors/configurator-textfield.adapter'; +import { + CONFIGURATION_TEXTFIELD_ADD_TO_CART_SERIALIZER, + CONFIGURATION_TEXTFIELD_NORMALIZER, + CONFIGURATION_TEXTFIELD_UPDATE_CART_ENTRY_SERIALIZER, +} from '../core/connectors/converters'; +import { OccConfiguratorTextfieldAddToCartSerializer } from './converters/occ-configurator-textfield-add-to-cart-serializer'; +import { OccConfiguratorTextfieldNormalizer } from './converters/occ-configurator-textfield-normalizer'; +import { OccConfiguratorTextfieldUpdateCartEntrySerializer } from './converters/occ-configurator-textfield-update-cart-entry-serializer'; +import { defaultOccConfiguratorTextfieldConfigFactory } from './default-occ-configurator-textfield-config'; +import { OccConfiguratorTextfieldAdapter } from './occ-configurator-textfield.adapter'; + +@NgModule({ + imports: [ + CommonModule, + + ConfigModule.withConfigFactory( + defaultOccConfiguratorTextfieldConfigFactory + ), + ], + providers: [ + { + provide: ConfiguratorTextfieldAdapter, + useClass: OccConfiguratorTextfieldAdapter, + }, + { + provide: CONFIGURATION_TEXTFIELD_NORMALIZER, + useExisting: OccConfiguratorTextfieldNormalizer, + multi: true, + }, + { + provide: CONFIGURATION_TEXTFIELD_ADD_TO_CART_SERIALIZER, + useExisting: OccConfiguratorTextfieldAddToCartSerializer, + multi: true, + }, + { + provide: CONFIGURATION_TEXTFIELD_UPDATE_CART_ENTRY_SERIALIZER, + useExisting: OccConfiguratorTextfieldUpdateCartEntrySerializer, + multi: true, + }, + ], +}) +export class TextfieldConfiguratorOccModule {} diff --git a/feature-libs/product/configurators/textfield/public_api.ts b/feature-libs/product-configurator/textfield/public_api.ts similarity index 65% rename from feature-libs/product/configurators/textfield/public_api.ts rename to feature-libs/product-configurator/textfield/public_api.ts index 8eaff2f26ab..33ad0979c24 100644 --- a/feature-libs/product/configurators/textfield/public_api.ts +++ b/feature-libs/product-configurator/textfield/public_api.ts @@ -2,4 +2,4 @@ * Public API Surface of textfield configurator */ -export * from './src/index'; +export * from './index'; diff --git a/feature-libs/product-configurator/textfield/root/default-textfield-routing-config.ts b/feature-libs/product-configurator/textfield/root/default-textfield-routing-config.ts new file mode 100644 index 00000000000..1d83c8dedfa --- /dev/null +++ b/feature-libs/product-configurator/textfield/root/default-textfield-routing-config.ts @@ -0,0 +1,11 @@ +import { RoutingConfig } from '@spartacus/core'; + +export const defaultTextfieldRoutingConfig: RoutingConfig = { + routing: { + routes: { + configureTEXTFIELD: { + paths: ['configure/textfield/:ownerType/entityKey/:entityKey'], + }, + }, + }, +}; diff --git a/feature-libs/product-configurator/textfield/root/index.ts b/feature-libs/product-configurator/textfield/root/index.ts new file mode 100644 index 00000000000..ee864cc85ff --- /dev/null +++ b/feature-libs/product-configurator/textfield/root/index.ts @@ -0,0 +1,3 @@ +export * from './textfield-configurator-root-feature.module'; +export * from './textfield-configurator-root.module'; +export * from './textfield-configurator-routing.module'; diff --git a/feature-libs/product-configurator/textfield/root/ng-package.json b/feature-libs/product-configurator/textfield/root/ng-package.json new file mode 100644 index 00000000000..23e0f90b954 --- /dev/null +++ b/feature-libs/product-configurator/textfield/root/ng-package.json @@ -0,0 +1,11 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts", + "umdModuleIds": { + "@spartacus/core": "core", + "@spartacus/storefront": "storefront", + "@ng-select/ng-select": "ngSelect" + } + } +} diff --git a/feature-libs/product-configurator/textfield/root/public_api.ts b/feature-libs/product-configurator/textfield/root/public_api.ts new file mode 100644 index 00000000000..33ad0979c24 --- /dev/null +++ b/feature-libs/product-configurator/textfield/root/public_api.ts @@ -0,0 +1,5 @@ +/* + * Public API Surface of textfield configurator + */ + +export * from './index'; diff --git a/feature-libs/product-configurator/textfield/root/textfield-configurator-root-feature.module.ts b/feature-libs/product-configurator/textfield/root/textfield-configurator-root-feature.module.ts new file mode 100644 index 00000000000..3c90011641d --- /dev/null +++ b/feature-libs/product-configurator/textfield/root/textfield-configurator-root-feature.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { provideDefaultConfig } from '@spartacus/core'; + +/** + * Contains feature module configuration + */ + +@NgModule({ + imports: [], + providers: [ + provideDefaultConfig({ + featureModules: { + textfield: { + cmsComponents: ['TextfieldConfigurationForm'], + }, + }, + }), + ], +}) +export class TextfieldConfiguratorRootFeatureModule {} diff --git a/feature-libs/product-configurator/textfield/root/textfield-configurator-root.module.ts b/feature-libs/product-configurator/textfield/root/textfield-configurator-root.module.ts new file mode 100644 index 00000000000..aebb8aae50d --- /dev/null +++ b/feature-libs/product-configurator/textfield/root/textfield-configurator-root.module.ts @@ -0,0 +1,50 @@ +import { CommonModule } from '@angular/common'; +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { provideDefaultConfig } from '@spartacus/core'; +import { CommonConfiguratorModule } from '@spartacus/product-configurator/common'; +import { + CmsPageGuard, + LayoutConfig, + PageLayoutComponent, +} from '@spartacus/storefront'; +import { TextfieldConfiguratorRootFeatureModule } from './textfield-configurator-root-feature.module'; +import { TextfieldConfiguratorRoutingModule } from './textfield-configurator-routing.module'; + +/** + * Exposes the root modules that we need to statically load. Contains page mappings + */ +@NgModule({ + imports: [ + CommonModule, + CommonConfiguratorModule, + TextfieldConfiguratorRootFeatureModule, + TextfieldConfiguratorRoutingModule.forRoot(), + RouterModule.forChild([ + { + path: null, + component: PageLayoutComponent, + data: { + cxRoute: 'configureTEXTFIELD', + }, + canActivate: [CmsPageGuard], + }, + ]), + ], + providers: [ + provideDefaultConfig({ + layoutSlots: { + TextfieldConfigurationTemplate: { + slots: ['TextfieldConfigContent'], + }, + }, + }), + ], +}) +export class TextfieldConfiguratorRootModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: TextfieldConfiguratorRootModule, + }; + } +} diff --git a/feature-libs/product-configurator/textfield/root/textfield-configurator-routing.module.ts b/feature-libs/product-configurator/textfield/root/textfield-configurator-routing.module.ts new file mode 100644 index 00000000000..ad2b05e5a24 --- /dev/null +++ b/feature-libs/product-configurator/textfield/root/textfield-configurator-routing.module.ts @@ -0,0 +1,22 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { + provideDefaultConfig, + RoutingModule as CoreRoutingModule, +} from '@spartacus/core'; +import { CmsRouteModule } from '@spartacus/storefront'; +import { defaultTextfieldRoutingConfig } from './default-textfield-routing-config'; + +/** + * Provides the default cx routing configuration for the textfield configurator + */ +@NgModule({ + imports: [CoreRoutingModule.forRoot(), CmsRouteModule], +}) +export class TextfieldConfiguratorRoutingModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: TextfieldConfiguratorRoutingModule, + providers: [provideDefaultConfig(defaultTextfieldRoutingConfig)], + }; + } +} diff --git a/feature-libs/product-configurator/textfield/styles/_configurator-textfield-add-to-cart-button.scss b/feature-libs/product-configurator/textfield/styles/_configurator-textfield-add-to-cart-button.scss new file mode 100644 index 00000000000..73c081ea6e9 --- /dev/null +++ b/feature-libs/product-configurator/textfield/styles/_configurator-textfield-add-to-cart-button.scss @@ -0,0 +1,13 @@ +%cx-configurator-textfield-add-to-cart-button { + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding: 15px; + justify-content: flex-end; + + .cx-add-to-cart-btn { + @include media-breakpoint-up(sm) { + width: 50%; + } + } +} diff --git a/feature-libs/product-configurator/textfield/styles/_configurator-textfield-form.scss b/feature-libs/product-configurator/textfield/styles/_configurator-textfield-form.scss new file mode 100644 index 00000000000..a573bbf62a3 --- /dev/null +++ b/feature-libs/product-configurator/textfield/styles/_configurator-textfield-form.scss @@ -0,0 +1,9 @@ +%cx-configurator-textfield-form { + display: flex; + flex-direction: column; + + .cx-attribute { + padding: 6px 40px; + background-color: var(--cx-color-background); + } +} diff --git a/feature-libs/product-configurator/textfield/styles/_configurator-textfield-input-field.scss b/feature-libs/product-configurator/textfield/styles/_configurator-textfield-input-field.scss new file mode 100644 index 00000000000..2c1aac56460 --- /dev/null +++ b/feature-libs/product-configurator/textfield/styles/_configurator-textfield-input-field.scss @@ -0,0 +1,29 @@ +%cx-configurator-textfield-input-field { + display: flex; + flex-direction: column; + margin-inline-start: 17px; + padding-block-start: 10px; + + label { + @include type('5'); + padding-block-start: 10px; + } + + .form-group { + margin-block-end: 0.5rem; + } + + @include media-breakpoint-up(md) { + label, + .form-group { + inline-size: 75%; + } + } + + @include media-breakpoint-down(sm) { + label, + .form-group { + inline-size: 100%; + } + } +} diff --git a/feature-libs/product-configurator/textfield/styles/_index.scss b/feature-libs/product-configurator/textfield/styles/_index.scss new file mode 100644 index 00000000000..3dfa4f7e981 --- /dev/null +++ b/feature-libs/product-configurator/textfield/styles/_index.scss @@ -0,0 +1,4 @@ +@import './configurator-textfield-input-field'; +@import './configurator-textfield-form'; +@import './configurator-textfield-add-to-cart-button'; +@import './pages/index'; diff --git a/feature-libs/product-configurator/textfield/styles/pages/_configuration-textfield-page.scss b/feature-libs/product-configurator/textfield/styles/pages/_configuration-textfield-page.scss new file mode 100644 index 00000000000..8caf3046a4c --- /dev/null +++ b/feature-libs/product-configurator/textfield/styles/pages/_configuration-textfield-page.scss @@ -0,0 +1,16 @@ +%TextfieldConfigurationTemplate { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + align-content: flex-start; + padding: 2rem 0 2rem 0; + max-width: 1140px; + margin: auto; + + @include media-breakpoint-up(lg) { + cx-page-slot.TextfieldConfigContent { + max-width: 75%; + } + } +} diff --git a/feature-libs/product-configurator/textfield/styles/pages/_index.scss b/feature-libs/product-configurator/textfield/styles/pages/_index.scss new file mode 100644 index 00000000000..2c33ddebc3e --- /dev/null +++ b/feature-libs/product-configurator/textfield/styles/pages/_index.scss @@ -0,0 +1 @@ +@import './configuration-textfield-page'; diff --git a/feature-libs/product/configurators/textfield/src/textfield-configurator.module.ts b/feature-libs/product-configurator/textfield/textfield-configurator.module.ts similarity index 52% rename from feature-libs/product/configurators/textfield/src/textfield-configurator.module.ts rename to feature-libs/product-configurator/textfield/textfield-configurator.module.ts index 032bbed22ab..e75e1146793 100644 --- a/feature-libs/product/configurators/textfield/src/textfield-configurator.module.ts +++ b/feature-libs/product-configurator/textfield/textfield-configurator.module.ts @@ -1,11 +1,17 @@ import { NgModule } from '@angular/core'; import { TextfieldConfiguratorComponentsModule } from './components/textfield-configurator-components.module'; import { TextfieldConfiguratorCoreModule } from './core/textfield-configurator-core.module'; +import { TextfieldConfiguratorOccModule } from './occ/textfield-configurator-occ.module'; +/** + * Exposes the textfield configurator, a small configurator that only provides 3 attributes at product level without any dependencies between them, + * and in the first place serves as a template for other configurator implementations. + */ @NgModule({ imports: [ TextfieldConfiguratorCoreModule, TextfieldConfiguratorComponentsModule, + TextfieldConfiguratorOccModule, ], }) export class TextfieldConfiguratorModule {} diff --git a/feature-libs/product-configurator/tsconfig.lib.json b/feature-libs/product-configurator/tsconfig.lib.json new file mode 100644 index 00000000000..d0924442eea --- /dev/null +++ b/feature-libs/product-configurator/tsconfig.lib.json @@ -0,0 +1,28 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib", + "target": "es2015", + "module": "es2020", + "moduleResolution": "node", + "declaration": true, + "sourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "importHelpers": true, + "types": [], + "lib": ["es2020", "dom"], + "paths": { + "@spartacus/core": ["dist/core"], + "@spartacus/storefront": ["dist/storefrontlib"] + } + }, + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "fullTemplateTypeCheck": true, + "strictInjectionParameters": true, + "enableResourceInlining": true + }, + "exclude": ["test.ts", "**/*.spec.ts"] +} diff --git a/feature-libs/product-configurator/tsconfig.lib.prod.json b/feature-libs/product-configurator/tsconfig.lib.prod.json new file mode 100644 index 00000000000..cbae7942248 --- /dev/null +++ b/feature-libs/product-configurator/tsconfig.lib.prod.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.lib.json", + "angularCompilerOptions": { + "enableIvy": false + } +} diff --git a/feature-libs/product-configurator/tsconfig.schematics.json b/feature-libs/product-configurator/tsconfig.schematics.json new file mode 100644 index 00000000000..bc46282c561 --- /dev/null +++ b/feature-libs/product-configurator/tsconfig.schematics.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "lib": ["es2018", "dom"], + "declaration": true, + "module": "commonjs", + "moduleResolution": "node", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "sourceMap": true, + "strictNullChecks": true, + "target": "es6", + "types": ["jest", "node"], + "resolveJsonModule": true, + "esModuleInterop": true, + "paths": { + "@spartacus/schematics": ["../../projects/schematics/src/public_api"] + } + }, + "include": ["schematics/**/*.ts"], + "exclude": ["schematics/*/files/**/*", "schematics/**/*_spec.ts"] +} diff --git a/feature-libs/product-configurator/tsconfig.spec.json b/feature-libs/product-configurator/tsconfig.spec.json new file mode 100644 index 00000000000..989f30c62c8 --- /dev/null +++ b/feature-libs/product-configurator/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "types": ["jasmine", "node"], + "module": "es2020" + }, + "files": ["test.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/feature-libs/product-configurator/tslint.json b/feature-libs/product-configurator/tslint.json new file mode 100644 index 00000000000..e87a6d1bf33 --- /dev/null +++ b/feature-libs/product-configurator/tslint.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tslint.json", + "rules": { + "directive-selector": [true, "attribute", "cx", "camelCase"], + "component-selector": [true, "element", "cx", "kebab-case"] + } +} diff --git a/feature-libs/product/_index.scss b/feature-libs/product/_index.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/feature-libs/product/configurators/common/public_api.ts b/feature-libs/product/configurators/common/public_api.ts deleted file mode 100644 index bba3e59f314..00000000000 --- a/feature-libs/product/configurators/common/public_api.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* - * Public API Surface of configuration - */ - -export * from './src/index'; diff --git a/feature-libs/product/configurators/common/src/components/common-configurator-components.module.ts b/feature-libs/product/configurators/common/src/components/common-configurator-components.module.ts deleted file mode 100644 index a51d30e4614..00000000000 --- a/feature-libs/product/configurators/common/src/components/common-configurator-components.module.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { NgModule } from '@angular/core'; - -@NgModule({}) -export class CommonConfiguratorComponentsModule {} diff --git a/feature-libs/product/configurators/common/src/components/index.ts b/feature-libs/product/configurators/common/src/components/index.ts deleted file mode 100644 index 466a4558097..00000000000 --- a/feature-libs/product/configurators/common/src/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './common-configurator-components.module'; diff --git a/feature-libs/product/configurators/common/src/core/common-configurator-core.module.ts b/feature-libs/product/configurators/common/src/core/common-configurator-core.module.ts deleted file mode 100644 index 2008b1a1faa..00000000000 --- a/feature-libs/product/configurators/common/src/core/common-configurator-core.module.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { NgModule } from '@angular/core'; - -@NgModule({}) -export class CommonConfiguratorCoreModule {} diff --git a/feature-libs/product/configurators/common/src/core/index.ts b/feature-libs/product/configurators/common/src/core/index.ts deleted file mode 100644 index 5db929330dd..00000000000 --- a/feature-libs/product/configurators/common/src/core/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './common-configurator-core.module'; diff --git a/feature-libs/product/configurators/common/src/index.ts b/feature-libs/product/configurators/common/src/index.ts deleted file mode 100644 index 3410b53707d..00000000000 --- a/feature-libs/product/configurators/common/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './common-configurator.module'; -export * from './components/index'; -export * from './core/index'; diff --git a/feature-libs/product/configurators/cpq/ng-package.json b/feature-libs/product/configurators/cpq/ng-package.json deleted file mode 100644 index 2a9c6221b0e..00000000000 --- a/feature-libs/product/configurators/cpq/ng-package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", - "lib": { - "entryFile": "./public_api.ts" - } -} diff --git a/feature-libs/product/configurators/cpq/public_api.ts b/feature-libs/product/configurators/cpq/public_api.ts deleted file mode 100644 index df9cc95d309..00000000000 --- a/feature-libs/product/configurators/cpq/public_api.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* - * Public API Surface of cpq configurator - */ - -export * from './src/index'; diff --git a/feature-libs/product/configurators/cpq/src/components/cpq-configurator-components.module.ts b/feature-libs/product/configurators/cpq/src/components/cpq-configurator-components.module.ts deleted file mode 100644 index 2ffb4f6e54f..00000000000 --- a/feature-libs/product/configurators/cpq/src/components/cpq-configurator-components.module.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { NgModule } from '@angular/core'; - -@NgModule({}) -export class CpqConfiguratorComponentsModule {} diff --git a/feature-libs/product/configurators/cpq/src/components/index.ts b/feature-libs/product/configurators/cpq/src/components/index.ts deleted file mode 100644 index 28a09f609ff..00000000000 --- a/feature-libs/product/configurators/cpq/src/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './cpq-configurator-components.module'; diff --git a/feature-libs/product/configurators/cpq/src/core/cpq-configurator-core.module.ts b/feature-libs/product/configurators/cpq/src/core/cpq-configurator-core.module.ts deleted file mode 100644 index fcc92f8f419..00000000000 --- a/feature-libs/product/configurators/cpq/src/core/cpq-configurator-core.module.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { NgModule } from '@angular/core'; - -@NgModule({}) -export class CpqConfiguratorCoreModule {} diff --git a/feature-libs/product/configurators/cpq/src/core/index.ts b/feature-libs/product/configurators/cpq/src/core/index.ts deleted file mode 100644 index 12e75d60f79..00000000000 --- a/feature-libs/product/configurators/cpq/src/core/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './cpq-configurator-core.module'; diff --git a/feature-libs/product/configurators/cpq/src/cpq-configurator.module.ts b/feature-libs/product/configurators/cpq/src/cpq-configurator.module.ts deleted file mode 100644 index ebd3b794ae6..00000000000 --- a/feature-libs/product/configurators/cpq/src/cpq-configurator.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CpqConfiguratorComponentsModule } from './components/cpq-configurator-components.module'; -import { CpqConfiguratorCoreModule } from './core/cpq-configurator-core.module'; - -@NgModule({ - imports: [CpqConfiguratorCoreModule, CpqConfiguratorComponentsModule], -}) -export class CpqConfiguratorModule {} diff --git a/feature-libs/product/configurators/cpq/src/index.ts b/feature-libs/product/configurators/cpq/src/index.ts deleted file mode 100644 index a80b8705451..00000000000 --- a/feature-libs/product/configurators/cpq/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './components/index'; -export * from './core/index'; -export * from './cpq-configurator.module'; diff --git a/feature-libs/product/configurators/ng-package.json b/feature-libs/product/configurators/ng-package.json deleted file mode 100644 index 38e01ac17de..00000000000 --- a/feature-libs/product/configurators/ng-package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", - "lib": { - "entryFile": "./public_api.ts" - } -} diff --git a/feature-libs/product/configurators/public_api.ts b/feature-libs/product/configurators/public_api.ts deleted file mode 100644 index a0bbd82633e..00000000000 --- a/feature-libs/product/configurators/public_api.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Public API Surface of product configuration - */ - -export * from '@spartacus/product/configurators/common'; -export * from '@spartacus/product/configurators/cpq'; -export * from '@spartacus/product/configurators/textfield'; -export * from '@spartacus/product/configurators/variant'; diff --git a/feature-libs/product/configurators/textfield/ng-package.json b/feature-libs/product/configurators/textfield/ng-package.json deleted file mode 100644 index 2a9c6221b0e..00000000000 --- a/feature-libs/product/configurators/textfield/ng-package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", - "lib": { - "entryFile": "./public_api.ts" - } -} diff --git a/feature-libs/product/configurators/textfield/src/components/index.ts b/feature-libs/product/configurators/textfield/src/components/index.ts deleted file mode 100644 index b09249b2db3..00000000000 --- a/feature-libs/product/configurators/textfield/src/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './textfield-configurator-components.module'; diff --git a/feature-libs/product/configurators/textfield/src/components/textfield-configurator-components.module.ts b/feature-libs/product/configurators/textfield/src/components/textfield-configurator-components.module.ts deleted file mode 100644 index 38789ede073..00000000000 --- a/feature-libs/product/configurators/textfield/src/components/textfield-configurator-components.module.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { NgModule } from '@angular/core'; - -@NgModule({}) -export class TextfieldConfiguratorComponentsModule {} diff --git a/feature-libs/product/configurators/textfield/src/core/textfield-configurator-core.module.ts b/feature-libs/product/configurators/textfield/src/core/textfield-configurator-core.module.ts deleted file mode 100644 index 49337b37470..00000000000 --- a/feature-libs/product/configurators/textfield/src/core/textfield-configurator-core.module.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { NgModule } from '@angular/core'; - -@NgModule({}) -export class TextfieldConfiguratorCoreModule {} diff --git a/feature-libs/product/configurators/variant/ng-package.json b/feature-libs/product/configurators/variant/ng-package.json deleted file mode 100644 index 2a9c6221b0e..00000000000 --- a/feature-libs/product/configurators/variant/ng-package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", - "lib": { - "entryFile": "./public_api.ts" - } -} diff --git a/feature-libs/product/configurators/variant/public_api.ts b/feature-libs/product/configurators/variant/public_api.ts deleted file mode 100644 index 79b54acea44..00000000000 --- a/feature-libs/product/configurators/variant/public_api.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* - * Public API Surface of variant configurator - */ - -export * from './src/index'; diff --git a/feature-libs/product/configurators/variant/src/components/index.ts b/feature-libs/product/configurators/variant/src/components/index.ts deleted file mode 100644 index ad6beba7f6b..00000000000 --- a/feature-libs/product/configurators/variant/src/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './variant-configurator-components.module'; diff --git a/feature-libs/product/configurators/variant/src/components/variant-configurator-components.module.ts b/feature-libs/product/configurators/variant/src/components/variant-configurator-components.module.ts deleted file mode 100644 index cafd0657035..00000000000 --- a/feature-libs/product/configurators/variant/src/components/variant-configurator-components.module.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { NgModule } from '@angular/core'; - -@NgModule({}) -export class VariantConfiguratorComponentsModule {} diff --git a/feature-libs/product/configurators/variant/src/core/index.ts b/feature-libs/product/configurators/variant/src/core/index.ts deleted file mode 100644 index bdcd7de1152..00000000000 --- a/feature-libs/product/configurators/variant/src/core/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './variant-configurator-core.module'; diff --git a/feature-libs/product/configurators/variant/src/core/variant-configurator-core.module.ts b/feature-libs/product/configurators/variant/src/core/variant-configurator-core.module.ts deleted file mode 100644 index 499477b3f81..00000000000 --- a/feature-libs/product/configurators/variant/src/core/variant-configurator-core.module.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { NgModule } from '@angular/core'; - -@NgModule({}) -export class VariantConfiguratorCoreModule {} diff --git a/feature-libs/product/configurators/variant/src/index.ts b/feature-libs/product/configurators/variant/src/index.ts deleted file mode 100644 index a15078665fd..00000000000 --- a/feature-libs/product/configurators/variant/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './components/index'; -export * from './core/index'; -export * from './variant-configurator.module'; diff --git a/feature-libs/product/configurators/variant/src/variant-configurator.module.ts b/feature-libs/product/configurators/variant/src/variant-configurator.module.ts deleted file mode 100644 index 062a0c81a54..00000000000 --- a/feature-libs/product/configurators/variant/src/variant-configurator.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NgModule } from '@angular/core'; -import { VariantConfiguratorComponentsModule } from './components/variant-configurator-components.module'; -import { VariantConfiguratorCoreModule } from './core/variant-configurator-core.module'; - -@NgModule({ - imports: [VariantConfiguratorCoreModule, VariantConfiguratorComponentsModule], -}) -export class VariantConfiguratorModule {} diff --git a/feature-libs/product/index.spec.ts b/feature-libs/product/index.spec.ts new file mode 100644 index 00000000000..98c45d22f4c --- /dev/null +++ b/feature-libs/product/index.spec.ts @@ -0,0 +1,5 @@ +// this test can be removed when any code and tests are added to this lib +// otherwise pipeline fails when run `ng test product --code-coverage` +it('dummy test', () => { + expect(true).toBe(true); +}); diff --git a/feature-libs/product/package.json b/feature-libs/product/package.json index ed7694eea01..a973b2ad54f 100644 --- a/feature-libs/product/package.json +++ b/feature-libs/product/package.json @@ -14,9 +14,7 @@ "dependencies": { "tslib": "^2.0.0" }, - "peerDependencies": { - "@angular/core": "^10.1.0" - }, + "peerDependencies": {}, "publishConfig": { "access": "public" } diff --git a/feature-libs/product/public_api.ts b/feature-libs/product/public_api.ts index af13cbd73a3..164e55b3f3d 100644 --- a/feature-libs/product/public_api.ts +++ b/feature-libs/product/public_api.ts @@ -1,5 +1,4 @@ /* * Public API Surface of product */ - -export * from '@spartacus/product/configurators'; +export {}; diff --git a/feature-libs/product/tsconfig.spec.json b/feature-libs/product/tsconfig.spec.json index c1d29e83b65..989f30c62c8 100644 --- a/feature-libs/product/tsconfig.spec.json +++ b/feature-libs/product/tsconfig.spec.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/spec", - "types": ["jasmine", "node"] + "types": ["jasmine", "node"], + "module": "es2020" }, "files": ["test.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"] diff --git a/package.json b/package.json index 36073b5f4a9..afb75318eb2 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "build": "ng build storefrontapp --prod", "build:assets": "yarn --cwd ./projects/assets build", - "build:libs": "ng build core --prod && ng build storefrontlib --prod && yarn build:schematics && yarn build:organization && yarn build:product && yarn build:storefinder && yarn build:qualtrics && yarn build:assets && yarn build:incubator && yarn build:cdc && yarn build:cds:lib && yarn build:setup", + "build:libs": "yarn ngcc:lock:remove && ng build core --prod && ng build storefrontlib --prod && yarn build:schematics && yarn build:organization && yarn build:product && yarn build:product-configurator && yarn build:storefinder && yarn build:qualtrics && yarn build:assets && yarn build:incubator && yarn build:cdc && yarn build:cds:lib && yarn build:setup", "build:incubator": "ng build incubator --prod", "build:setup": "ng build setup --prod", "e2e:cy:open": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:open", @@ -53,7 +53,7 @@ "start:ssl": "ng serve --ssl", "start:pwa": "cd ./dist/storefrontapp/ && http-server -p 4200", "test": "ng test", - "test:libs": "concurrently \"ng test core --code-coverage\" \"ng test storefrontlib --code-coverage\" \"ng test organization --code-coverage\" \"ng test storefinder --code-coverage\" \"ng test qualtrics --code-coverage\" \"ng test product --code-coverage\" \"ng test cdc --code-coverage\" \"ng test setup --code-coverage\"", + "test:libs": "concurrently \"ng test core --code-coverage\" \"ng test storefrontlib --code-coverage\" \"ng test organization --code-coverage\" \"ng test storefinder --code-coverage\" \"ng test qualtrics --code-coverage\" \"ng test product --code-coverage\" \"ng test product-configurator --code-coverage\" \"ng test cdc --code-coverage\" \"ng test setup --code-coverage\"", "test:storefront:lib": "ng test storefrontlib --sourceMap --code-coverage", "dev:ssr": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 ng run storefrontapp:serve-ssr", "serve:ssr:dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 node dist/storefrontapp-server/main.js", @@ -91,6 +91,10 @@ "build:product": "ng build product --prod", "release:organization:with-changelog": "cd feature-libs/organization && release-it && cd ../..", "release:product:with-changelog": "cd feature-libs/product && release-it && cd ../..", + "build:product-configurator": "yarn --cwd feature-libs/product-configurator run build:schematics && ng build product-configurator --prod", + "e2e:cy:run:product-configuration":"yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:run:product-configuration", + "e2e:cy:start-run:product-configuration":"start-server-and-test start http-get://localhost:4200 e2e:cy:run:product-configuration", + "release:product-configurator:with-changelog": "cd feature-libs/product-configurator && release-it && cd ../..", "build:storefinder": "yarn --cwd feature-libs/storefinder run build:schematics && ng build storefinder --prod", "release:storefinder:with-changelog": "cd feature-libs/storefinder && release-it && cd ../..", "build:qualtrics": "yarn --cwd feature-libs/qualtrics run build:schematics && ng build qualtrics --prod", @@ -105,7 +109,8 @@ "start:ci:b2b": "cross-env SPARTACUS_BASE_URL=https://spartacus-devci767.eastus.cloudapp.azure.com:9002 SPARTACUS_API_PREFIX=/occ/v2/ SPARTACUS_B2B=true yarn start", "e2e:cy:open:b2b": "yarn --cwd ./projects/storefrontapp-e2e-cypress run cy:open:b2b", "config:update": "ts-node ./tools/config/index.ts --fix", - "config:check": "ts-node ./tools/config/index.ts" + "config:check": "ts-node ./tools/config/index.ts", + "ngcc:lock:remove": "rm -rf ./node_modules/.cli-ngcc/*.lock" }, "private": false, "dependencies": { diff --git a/projects/core/src/cart/facade/active-cart.service.spec.ts b/projects/core/src/cart/facade/active-cart.service.spec.ts index d41c9a3001e..5290a028d2f 100644 --- a/projects/core/src/cart/facade/active-cart.service.spec.ts +++ b/projects/core/src/cart/facade/active-cart.service.spec.ts @@ -400,6 +400,27 @@ describe('ActiveCartService', () => { }); }); + describe('getLastEntry', () => { + it('should return last entry by product code', () => { + spyOn(multiCartService, 'getLastEntry').and.returnValue( + of(mockCartEntry) + ); + service['activeCartId$'] = of('cartId'); + + let result; + service + .getLastEntry('code123') + .subscribe((entry) => (result = entry)) + .unsubscribe(); + + expect(result).toEqual(mockCartEntry); + expect(multiCartService['getLastEntry']).toHaveBeenCalledWith( + 'cartId', + 'code123' + ); + }); + }); + describe('addEmail', () => { it('should assign email to active cart', () => { service['activeCartId$'] = of('cartId'); diff --git a/projects/core/src/cart/facade/active-cart.service.ts b/projects/core/src/cart/facade/active-cart.service.ts index f632fb5e520..aa967d70506 100644 --- a/projects/core/src/cart/facade/active-cart.service.ts +++ b/projects/core/src/cart/facade/active-cart.service.ts @@ -312,7 +312,7 @@ export class ActiveCartService implements OnDestroy { ); } - private requireLoadedCart( + requireLoadedCart( customCartSelector$?: Observable> ): Observable> { // For guest cart merge we want to filter guest cart in the whole stream diff --git a/projects/core/src/cart/facade/multi-cart.service.spec.ts b/projects/core/src/cart/facade/multi-cart.service.spec.ts index 91dd89a7e29..c219cc7754e 100644 --- a/projects/core/src/cart/facade/multi-cart.service.spec.ts +++ b/projects/core/src/cart/facade/multi-cart.service.spec.ts @@ -421,6 +421,30 @@ describe('MultiCartService', () => { }); }); + describe('getLastEntry', () => { + it('should return last cart entry', () => { + let result; + service.getLastEntry('xxx', '1234').subscribe((cart) => { + result = cart; + }); + + expect(result).toEqual(undefined); + + store.dispatch( + new CartActions.LoadCartSuccess({ + userId: 'userId', + extraData: { + active: true, + }, + cart: testCart, + cartId: testCart.code, + }) + ); + + expect(result).toEqual(testCart.entries[1]); + }); + }); + describe('assignEmail', () => { it('should dispatch AddEmailToCart action', () => { service.assignEmail('cartId', 'userId', 'test@email.com'); diff --git a/projects/core/src/checkout/facade/checkout.service.spec.ts b/projects/core/src/checkout/facade/checkout.service.spec.ts index 8fe2875a2cc..1be7660a60f 100644 --- a/projects/core/src/checkout/facade/checkout.service.spec.ts +++ b/projects/core/src/checkout/facade/checkout.service.spec.ts @@ -132,14 +132,49 @@ describe('CheckoutService', () => { .unsubscribe(); expect(loaded).toBeTruthy(); }); + + it('should return false for fail', () => { + store.dispatch( + new CheckoutActions.LoadCheckoutDetailsFail(new Error()) + ); + + let loaded: boolean; + service + .getCheckoutDetailsLoaded() + .subscribe((data) => { + loaded = data; + }) + .unsubscribe(); + expect(loaded).toBeFalsy(); + }); + }); + + describe('is loading', () => { + it('should return true in case loading was triggered', () => { + store.dispatch( + new CheckoutActions.LoadCheckoutDetails({ + userId: userId, + cartId: cart.code, + }) + ); + + let loaded: boolean; + service + .isLoading() + .subscribe((data) => { + loaded = data; + }) + .unsubscribe(); + expect(loaded).toBeTruthy(); + }); }); - it('should return false for fail', () => { + it('should return false in case checkout load failed', () => { store.dispatch(new CheckoutActions.LoadCheckoutDetailsFail(new Error())); let loaded: boolean; service - .getCheckoutDetailsLoaded() + .isLoading() .subscribe((data) => { loaded = data; }) diff --git a/projects/core/src/checkout/facade/checkout.service.ts b/projects/core/src/checkout/facade/checkout.service.ts index fce73edd5d4..f6e9bc6a156 100644 --- a/projects/core/src/checkout/facade/checkout.service.ts +++ b/projects/core/src/checkout/facade/checkout.service.ts @@ -175,6 +175,15 @@ export class CheckoutService { ); } + /** + * Check if checkout details are stable (no longer loading) + */ + isLoading(): Observable { + return this.checkoutStore.pipe( + select(CheckoutSelectors.getCheckoutLoading) + ); + } + /** * Get order details */ diff --git a/projects/core/src/checkout/store/selectors/checkout.selectors.ts b/projects/core/src/checkout/store/selectors/checkout.selectors.ts index c0755837fba..2c3cb68fccf 100644 --- a/projects/core/src/checkout/store/selectors/checkout.selectors.ts +++ b/projects/core/src/checkout/store/selectors/checkout.selectors.ts @@ -108,6 +108,13 @@ export const getCheckoutDetailsLoaded: MemoizedSelector< !StateUtils.loaderLoadingSelector(state) ); +export const getCheckoutLoading: MemoizedSelector< + StateWithCheckout, + boolean +> = createSelector(getCheckoutStepsState, (state) => + StateUtils.loaderLoadingSelector(state) +); + export const getPoNumer: MemoizedSelector< StateWithCheckout, string diff --git a/projects/core/src/model/order.model.ts b/projects/core/src/model/order.model.ts index 30a4a7209ca..adb7af46056 100644 --- a/projects/core/src/model/order.model.ts +++ b/projects/core/src/model/order.model.ts @@ -19,6 +19,7 @@ export interface DeliveryMode { } export interface OrderEntry { + orderCode?: string; basePrice?: Price; deliveryMode?: DeliveryMode; deliveryPointOfService?: PointOfService; diff --git a/projects/core/src/occ/adapters/checkout/converters/occ-order-normalizer.ts b/projects/core/src/occ/adapters/checkout/converters/occ-order-normalizer.ts index 831e7783876..1b4c476ead3 100644 --- a/projects/core/src/occ/adapters/checkout/converters/occ-order-normalizer.ts +++ b/projects/core/src/occ/adapters/checkout/converters/occ-order-normalizer.ts @@ -18,7 +18,7 @@ export class OccOrderNormalizer implements Converter { if (source.entries) { target.entries = source.entries.map((entry) => - this.convertOrderEntry(entry) + this.convertOrderEntry(entry, source.code) ); } @@ -27,24 +27,25 @@ export class OccOrderNormalizer implements Converter { ...consignment, entries: consignment.entries.map((entry) => ({ ...entry, - orderEntry: this.convertOrderEntry(entry.orderEntry), + orderEntry: this.convertOrderEntry(entry.orderEntry, source.code), })), })); } if (source.unconsignedEntries) { target.unconsignedEntries = source.unconsignedEntries.map((entry) => - this.convertOrderEntry(entry) + this.convertOrderEntry(entry, source.code) ); } return target; } - private convertOrderEntry(source: Occ.OrderEntry): OrderEntry { + private convertOrderEntry(source: Occ.OrderEntry, code: string): OrderEntry { return { ...source, product: this.converter.convert(source.product, PRODUCT_NORMALIZER), + orderCode: code, }; } } diff --git a/projects/core/src/occ/adapters/product/default-occ-product-config.ts b/projects/core/src/occ/adapters/product/default-occ-product-config.ts index 68304a9ac8d..efb3bbba68d 100644 --- a/projects/core/src/occ/adapters/product/default-occ-product-config.ts +++ b/projects/core/src/occ/adapters/product/default-occ-product-config.ts @@ -11,7 +11,7 @@ export const defaultOccProductConfig: OccConfig = { list: 'products/${productCode}?fields=code,name,summary,price(formattedValue),images(DEFAULT,galleryIndex)', details: - 'products/${productCode}?fields=averageRating,stock(DEFAULT),description,availableForPickup,code,url,price(DEFAULT),numberOfReviews,manufacturer,categories(FULL),priceRange,multidimensional,configuratorType,configurable,tags,images(FULL)', + 'products/${productCode}?fields=averageRating,stock(DEFAULT),description,availableForPickup,code,url,price(DEFAULT),numberOfReviews,manufacturer,categories(FULL),priceRange,multidimensional,tags,images(FULL)', attributes: 'products/${productCode}?fields=classifications', variants: 'products/${productCode}?fields=name,purchasable,baseOptions(DEFAULT),baseProduct,variantOptions(DEFAULT),variantType', @@ -24,7 +24,7 @@ export const defaultOccProductConfig: OccConfig = { 'products/${productCode}/references?fields=DEFAULT,references(target(images(FULL)))', // tslint:disable:max-line-length productSearch: - 'products/search?fields=products(code,name,summary,price(FULL),images(DEFAULT),stock(FULL),averageRating,variantOptions),facets,breadcrumbs,pagination(DEFAULT),sorts(DEFAULT),freeTextSearch,currentQuery', + 'products/search?fields=products(code,name,summary,configurable,configuratorType,price(FULL),images(DEFAULT),stock(FULL),averageRating,variantOptions),facets,breadcrumbs,pagination(DEFAULT),sorts(DEFAULT),freeTextSearch,currentQuery', // tslint:enable productSuggestions: 'products/suggestions', }, diff --git a/projects/core/src/occ/occ-models/occ-endpoints.model.ts b/projects/core/src/occ/occ-models/occ-endpoints.model.ts index 4831ca71d35..470d0535782 100644 --- a/projects/core/src/occ/occ-models/occ-endpoints.model.ts +++ b/projects/core/src/occ/occ-models/occ-endpoints.model.ts @@ -247,6 +247,74 @@ export interface OccEndpoints { * @member {string} */ addressVerification?: string | OccEndpoint; + /** + * Endpoint for create configuration + * + * @member {string} + */ + createVariantConfiguration?: string; + /** + * Endpoint for create configuration for the textfield configurator + * + * @member {string} + */ + createTextfieldConfiguration?: string; + /** + * Endpoint for add textfield configuration to cart + * + * @member {string} + */ + addTextfieldConfigurationToCart?: string; + /** + * Endpoint for reading textfield configuration attached to the cart entry + */ + readTextfieldConfigurationForCartEntry?: string; + /** + * Endpoint for updating textfield configuration attached to the cart entry + */ + updateTextfieldConfigurationForCartEntry?: string; + /** + * Endpoint to read configuration + * + * @member {string} + */ + readVariantConfiguration?: string; + /** + * Endpoint to update configuration + * + * @member {string} + */ + updateVariantConfiguration?: string; + /** + * Endpoint to add configuration to cart + * + * @member {string} + */ + addVariantConfigurationToCart?: string; + /** + * Endpoint for reading configuration attached to the cart entry + */ + readVariantConfigurationForCartEntry?: string; + /** + * Endpoint for updating configuration attached to the cart entry + */ + updateVariantConfigurationForCartEntry?: string; + /** + * Endpoint for reading configuration overview attached to the order entry + */ + readVariantConfigurationOverviewForOrderEntry?: string; + /** + * Endpoint to read configuration price + * + * @member {string} + */ + readVariantConfigurationPriceSummary?: string; + /** + * Endpoint to get configuration Overview + * + * @member {string} + */ + getVariantConfigurationOverview?: string; /** * Endpoint for consignment tracking * diff --git a/projects/core/src/occ/occ-models/occ.models.ts b/projects/core/src/occ/occ-models/occ.models.ts index d683cb2fc21..87e1ee289c9 100644 --- a/projects/core/src/occ/occ-models/occ.models.ts +++ b/projects/core/src/occ/occ-models/occ.models.ts @@ -1493,6 +1493,64 @@ export namespace Occ { * @member {boolean} [updateable] */ updateable?: boolean; + /** + * @member {StatusSummary[]} [statusSummaryList] + */ + statusSummaryList?: StatusSummary[]; + /** + * @member {ConfigurationInfo[]} [configurationInfos] + */ + configurationInfos?: ConfigurationInfo[]; + } + + /** + * + * An interface representing ConfigurationInfo. + * Provides information about configuration values of the entry. + */ + export interface ConfigurationInfo { + /** + * @member {string} [configurationLabel] + */ + configurationLabel?: string; + /** + * @member {string} [configurationValue] + */ + configurationValue?: string; + /** + * @member {string} [configuratorType] + */ + configuratorType?: string; + /** + * @member {string} [status] + */ + status?: string; + } + + /** + * Possible order entry statuses + */ + export enum OrderEntryStatus { + Success = 'SUCCESS', + Info = 'INFO', + Warning = 'WARNING', + Error = 'ERROR', + } + + /** + * + * An interface representing StatusSummary. + * Provides status including number of issues for configurable entry. + */ + export interface StatusSummary { + /** + * @member {number} [numberOfIssues] + */ + numberOfIssues?: number; + /** + * @member {string} [status] + */ + status?: OrderEntryStatus; } /** diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/accessibility/tabbing-order.config.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/accessibility/tabbing-order.config.ts index 253131fe31e..fbc3f2412b3 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/accessibility/tabbing-order.config.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/accessibility/tabbing-order.config.ts @@ -166,6 +166,70 @@ export const tabbingOrderConfig: TabbingOrderConfig = { { value: 'Submit', type: TabbingOrderTypes.BUTTON }, { value: 'Cancel', type: TabbingOrderTypes.BUTTON }, ], + productConfigurationPage: [ + { + type: TabbingOrderTypes.GENERIC_ELEMENT, + }, + { + value: 'Basics', + type: TabbingOrderTypes.LINK, + }, + { + value: 'Specification', + type: TabbingOrderTypes.LINK, + }, + { + value: 'Display', + type: TabbingOrderTypes.LINK, + }, + { + value: 'Lens', + type: TabbingOrderTypes.LINK, + }, + { + value: 'Options', + type: TabbingOrderTypes.LINK, + }, + { + value: 'attributeRadioButtonForm', + type: TabbingOrderTypes.RADIO, + }, + { + value: 'attributeRadioButtonForm', + type: TabbingOrderTypes.RADIO, + }, + { + value: 'attributeRadioButtonForm', + type: TabbingOrderTypes.RADIO, + }, + { + value: 'attributeRadioButtonForm', + type: TabbingOrderTypes.RADIO, + }, + { + value: 'Next', + type: TabbingOrderTypes.BUTTON, + }, + { + value: 'Add to Cart', + type: TabbingOrderTypes.BUTTON, + }, + ], + + productConfigurationOverview: [ + { + value: 'show more', + type: TabbingOrderTypes.LINK, + }, + { + value: 'Resolve Issues', + type: TabbingOrderTypes.LINK, + }, + { + value: 'Add to Cart', + type: TabbingOrderTypes.BUTTON, + }, + ], cart: [ { value: 'FUN Flash Single Use Camera, 27+12 pic', diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/product-configuration-overview.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/product-configuration-overview.ts new file mode 100644 index 00000000000..7c7e15e1281 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/product-configuration-overview.ts @@ -0,0 +1,127 @@ +import Chainable = Cypress.Chainable; + +const continueToCartButtonSelector = + 'cx-configurator-add-to-cart-button button'; +const resolveIssuesLinkSelector = + 'cx-configurator-overview-notification-banner button.cx-action-link'; + +/** + * Navigates to the configured product overview page. + * + * @param {string} shopName - shop name + * @param {string} productId - Product ID + * @return {Chainable} - New configuration overview window + */ +export function goToConfigOverviewPage( + shopName: string, + productId: string +): Chainable { + const location = `/${shopName}/en/USD/configure-overview/vc/product/entityKey/${productId}`; + return cy.visit(location).then(() => { + cy.location('pathname').should('contain', location); + cy.get('.VariantConfigurationOverviewTemplate').should('be.visible'); + this.checkConfigOverviewPageDisplayed(); + }); +} + +/** + * Verifies whether the product overview page is displayed. + */ +export function checkConfigOverviewPageDisplayed(): void { + cy.get('cx-configurator-overview-form').should('be.visible'); +} + +/** + * Verifies whether 'Continue to Cart' button is displayed. + */ +export function checkContinueToCartBtnDisplayed(): void { + cy.get('.cx-configurator-add-to-cart-btn button.btn-primary') + .contains('Continue to Cart') + .should('be.visible'); +} + +/** + * Navigates to the configuration page via configuration tab. + */ +export function navigateToConfigurationPage(): void { + cy.get('cx-configurator-tab-bar a:contains("Configuration")').click({ + force: true, + }); +} + +/** + * Clicks on 'Continue to cart' on the product overview page. + */ +export function clickContinueToCartBtnOnOP(): void { + cy.get(continueToCartButtonSelector) + .click() + .then(() => { + cy.get('h1').contains('Your Shopping Cart').should('be.visible'); + cy.get('cx-cart-details').should('be.visible'); + }); +} + +/** + * Clicks on 'Resolve Issues' link on the product overview page. + */ +export function clickOnResolveIssuesLinkOnOP(): void { + cy.get(resolveIssuesLinkSelector) + .click() + .then(() => { + cy.location('pathname').should('contain', '/cartEntry/entityKey/'); + }); +} + +/** + * Verifies whether the issues banner is displayed. + * + * @param element - HTML element + * @param {number} numberOfIssues - Expected number of conflicts + */ +export function checkNotificationBannerOnOP( + element, + numberOfIssues?: number +): void { + const resolveIssuesText = + ' must be resolved before checkout. Resolve Issues'; + element + .get('.cx-error-msg') + .first() + .invoke('text') + .then((text) => { + expect(text).contains(resolveIssuesText); + if (numberOfIssues > 1) { + const issues = text.replace(resolveIssuesText, '').trim(); + expect(issues).match(/^[0-9]/); + expect(issues).eq(numberOfIssues.toString()); + } + }); +} + +/** + * Verifies whether the issues banner is displayed and the number of issues are accurate. + * + * @param {number} numberOfIssues - Expected number of issues + */ +export function verifyNotificationBannerOnOP(numberOfIssues?: number): void { + cy.wait('@configure_overview'); + const element = cy.get('cx-configurator-overview-notification-banner', { + timeout: 10000, + }); + if (numberOfIssues) { + this.checkNotificationBannerOnOP(element, numberOfIssues); + } else { + element.should('not.contain.html', 'div.cx-error-msg'); + } +} +/** + * Registers OCC call for OV page in order to wait for it + */ +export function registerConfigurationOvOCC() { + cy.intercept( + 'GET', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/ccpconfigurator/*/configurationOverview?lang=en&curr=USD` + ).as('configure_overview'); +} diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/product-configuration.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/product-configuration.ts new file mode 100644 index 00000000000..13089048d71 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/product-configuration.ts @@ -0,0 +1,1058 @@ +import Chainable = Cypress.Chainable; +import { user } from '../sample-data/checkout-flow'; +import { + AddressData, + fillPaymentDetails, + fillShippingAddress, + PaymentDetails, +} from './checkout-forms'; +import { navigation } from './navigation'; + +const shippingAddressData: AddressData = user; +const billingAddress: AddressData = user; +const paymentDetailsData: PaymentDetails = user; + +const nextBtnSelector = + 'cx-configurator-previous-next-buttons button:contains("Next")'; +const previousBtnSelector = + 'cx-configurator-previous-next-buttons button:contains("Previous")'; +const addToCartButtonSelector = 'cx-configurator-add-to-cart-button button'; + +const conflictDetectedMsgSelector = '.cx-conflict-msg'; +const conflictHeaderGroupSelector = + 'cx-configurator-group-menu li.cx-menu-conflict'; + +const resolveIssuesLinkSelector = + 'cx-configure-cart-entry button.cx-action-link'; + +/** + * Navigates to the product configuration page. + * + * @param {string} shopName - shop name + * @param {string} productId - Product ID + * @return {Chainable} - New configuration window + */ +export function goToConfigurationPage(shopName: string, productId: string) { + registerConfigurationRoute(); + const location = `/${shopName}/en/USD/configure/vc/product/entityKey/${productId}`; + cy.visit(location); + cy.wait('@configure_product'); + this.checkConfigPageDisplayed(); +} + +export function registerConfigurationRoute() { + cy.intercept( + 'GET', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/products/*/configurators/ccpconfigurator?lang=en&curr=USD` + ).as('configure_product'); +} + +/** + * Navigates to the product detail page. + * + * @param {string} shopName - shop name + * @param {string} productId - Product ID + */ +export function goToPDPage(shopName: string, productId: string): void { + const location = `${shopName}/en/USD/product/${productId}/${productId}`; + cy.visit(location).then(() => { + checkLoadingMsgNotDisplayed(); + cy.location('pathname').should('contain', location); + cy.get('.ProductDetailsPageTemplate').should('be.visible'); + }); +} + +/** + * Navigates to the cart page. + * + * @param {string} shopName - shop name + */ +export function goToCart(shopName: string) { + const location = `/${shopName}/en/USD/cart`; + cy.visit(`/${shopName}/en/USD/cart`).then(() => { + cy.location('pathname').should('contain', location); + cy.get('h1').contains('Your Shopping Cart').should('be.visible'); + cy.get('cx-cart-details').should('be.visible'); + }); +} + +/** + * Verifies whether the loading message is not displayed. + */ +export function checkLoadingMsgNotDisplayed(): void { + cy.log('Wait until the loading notification is not displayed anymore'); + cy.get('cx-storefront').should('not.contain.value', 'Loading'); +} + +/** + * Verifies whether the global message is not displayed on the top of the configuration. + */ +export function checkGlobalMessageNotDisplayed(): void { + cy.get('cx-global-message').should('not.be.visible'); +} + +/** + * Verifies whether the updating configuration message is not displayed on the top of the configuration. + */ +export function checkUpdatingMessageNotDisplayed(): void { + cy.get('cx-configurator-update-message div.cx-update-msg').should( + 'not.be.visible' + ); +} + +/** + * Clicks on 'Add to Cart' button in catalog list. + */ +export function clickOnConfigureBtnInCatalog(): void { + cy.get('cx-configure-product a') + .click() + .then(() => { + cy.location('pathname').should('contain', '/product/entityKey/'); + this.checkConfigPageDisplayed(); + }); +} + +/** + * Clicks on the 'Edit Configuration' link in cart for a certain cart item. + * + * @param {number} cartItemIndex - Index of cart item + */ +export function clickOnEditConfigurationLink(cartItemIndex: number): void { + cy.get('cx-cart-item-list .cx-item-list-row') + .eq(cartItemIndex) + .find('cx-configure-cart-entry') + .within(() => { + cy.get('a:contains("Edit")') + .click() + .then(() => { + cy.location('pathname').should('contain', '/cartEntry/entityKey/'); + }); + }); +} + +/** + * Verifies whether the corresponding value ID is focused. + * + * @param {string} attributeName - Attribute name + * @param {string} uiType - UI type + * @param {string} valueName - Value name + */ +export function checkFocus( + attributeName: string, + uiType: string, + valueName: string +): void { + const attributeId = getAttributeId(attributeName, uiType); + const valueId = `${attributeId}--${valueName}`; + cy.focused().should('have.attr', 'id', valueId); +} + +/** + * Verifies whether the current group is active. + * + * @param {string} currentGroup - Active group + */ +function checkCurrentGroupActive(currentGroup: string): void { + cy.get( + 'cx-configurator-group-title:contains(' + `${currentGroup}` + ')' + ).should('be.visible'); + cy.get('a.active:contains(' + `${currentGroup}` + ')').should('be.visible'); +} + +/** + * Clicks on 'previous' or 'next' button. + * + * @param {string} btnSelector - Button selector + * @param {string} activeGroup - Name of the group that should be active after click + */ +function clickOnPreviousOrNextBtn( + btnSelector: string, + activeGroup: string +): void { + cy.get(btnSelector) + .click() + .then(() => { + checkUpdatingMessageNotDisplayed(); + checkCurrentGroupActive(activeGroup); + checkUpdatingMessageNotDisplayed(); + }); +} + +/** + * Clicks on the next group Button and verifies that an element of the next group is displayed. + * + * @param {string} nextGroup - Expected next group name + */ +export function clickOnNextBtn(nextGroup: string): void { + clickOnPreviousOrNextBtn(nextBtnSelector, nextGroup); +} + +/** + * Clicks on the previous group Button and verifies that an element of the previous group is displayed. + * + * @param {string} previousGroup - Expected previous group name + */ +export function clickOnPreviousBtn(previousGroup: string): void { + clickOnPreviousOrNextBtn(previousBtnSelector, previousGroup); +} + +/** + * Verifies whether the configuration page is displayed. + */ +export function checkConfigPageDisplayed(): void { + checkLoadingMsgNotDisplayed(); + checkGlobalMessageNotDisplayed(); + checkTabBarDisplayed(); + checkGroupTitleDisplayed(); + checkGroupFormDisplayed(); + checkPreviousAndNextBtnsDispalyed(); + checkPriceSummaryDisplayed(); + checkAddToCartBtnDisplayed(); + checkProductTitleDisplayed(); + checkShowMoreLinkAtProductTitleDisplayed(); +} + +/** + * Verifies whether the product title component is displayed. + */ +export function checkProductTitleDisplayed(): void { + checkUpdatingMessageNotDisplayed(); + cy.get('cx-configurator-product-title', { timeout: 10000 }).should( + 'be.visible' + ); +} + +/** + * Verifies whether 'show more' link is displayed in the product title component. + */ +export function checkShowMoreLinkAtProductTitleDisplayed(): void { + checkUpdatingMessageNotDisplayed(); + cy.get('a:contains("show more")').should('be.visible'); +} + +/** + * Verifies whether the product title component is displayed. + */ +function checkTabBarDisplayed(): void { + checkUpdatingMessageNotDisplayed(); + cy.get('cx-configurator-tab-bar').should('be.visible'); +} + +/** + * Verifies whether the 'previous' button is enabled. + */ +export function checkPreviousBtnEnabled(): void { + cy.get(previousBtnSelector).should('be.not.disabled'); +} + +/** + * Verifies whether the 'previous' button is disabled. + */ +export function checkPreviousBtnDisabled(): void { + cy.get(previousBtnSelector).should('be.disabled'); +} + +/** + * Verifies whether the 'next' button is enabled. + */ +export function checkNextBtnEnabled(): void { + cy.get(nextBtnSelector).should('be.not.disabled'); +} + +/** + * Verifies whether the 'next' button is disabled. + */ +export function checkNextBtnDisabled(): void { + cy.get(nextBtnSelector).should('be.disabled'); +} + +/** + * Verifies whether status icon is not displayed. + * + * @param {string} groupName - Group name + */ +export function checkStatusIconNotDisplayed(groupName: string): void { + cy.get( + '.' + `${'ERROR'}` + '.cx-menu-item>a:contains(' + `${groupName}` + ')' + ).should('not.exist'); + + cy.get( + '.' + `${'COMPLETE'}` + '.cx-menu-item>a:contains(' + `${groupName}` + ')' + ).should('not.exist'); +} + +/** + * Verifies whether status icon is displayed. + * + * @param {string} groupName - Group name + * @param {string} status - Status + */ +export function checkStatusIconDisplayed( + groupName: string, + status: string +): void { + cy.get( + '.' + `${status}` + '.cx-menu-item>a:contains(' + `${groupName}` + ')' + ).should('exist'); +} + +/** + * Verifies whether the attribute is displayed. + * + * @param {string} attributeName - Attribute name + * @param {string} uiType - UI type + */ +export function checkAttributeDisplayed( + attributeName: string, + uiType: string +): void { + const attributeId = getAttributeId(attributeName, uiType); + cy.get(`#${attributeId}`).should('be.visible'); +} + +/** + * Verifies whether the attribute is not displayed. + * + * @param {string} attributeName - Attribute name + * @param {string} uiType - UI type + */ +export function checkAttributeNotDisplayed( + attributeName: string, + uiType: string +): void { + const attributeId = getAttributeId(attributeName, uiType); + cy.get(`#${attributeId}`).should('be.not.visible'); +} + +/** + * Verifies whether the attribute value is displayed. + * + * @param {string} attributeName - Attribute name + * @param {string} uiType - UI type + * @param {string} valueName - Value name + */ +export function checkAttrValueDisplayed( + attributeName: string, + uiType: string, + valueName: string +): void { + const attributeId = getAttributeId(attributeName, uiType); + const valueId = `${attributeId}--${valueName}`; + cy.get(`#${valueId}`).should('be.visible'); +} + +/** + * Verifies whether the attribute value is not displayed. + * + * @param {string} attributeName - Attribute name + * @param {string} uiType - UI type + * @param {string} valueName - Value name + */ +export function checkAttrValueNotDisplayed( + attributeName: string, + uiType: string, + valueName: string +): void { + const attributeId = getAttributeId(attributeName, uiType); + const valueId = `${attributeId}--${valueName}`; + cy.get(`#${valueId}`).should('be.not.visible'); +} + +/** + * Retrieves attribute ID. + * + * @param {string} attributeName - Attribute name + * @param {string} uiType - UI type + * @return {string} - Attribute ID + */ +export function getAttributeId(attributeName: string, uiType: string): string { + return `cx-configurator--${uiType}--${attributeName}`; +} + +/** + * Retrieves the attribute label id. + * + * @param {string} attributeName - Attribute name + * @return {string} - Attribute label ID + */ +export function getAttributeLabelId(attributeName: string): string { + return `cx-configurator--label--${attributeName}`; +} + +/** + * Selects a corresponding attribute value. + * + * @param {string} attributeName - Attribute name + * @param {string} uiType - UI type + * @param {string} valueName - Value name + * @param {string} value - Value + */ +export function selectAttribute( + attributeName: string, + uiType: string, + valueName: string, + value?: string +): void { + const attributeId = getAttributeId(attributeName, uiType); + cy.log('attributeId: ' + attributeId); + const valueId = `${attributeId}--${valueName}`; + cy.log('valueId: ' + valueId); + + switch (uiType) { + case 'radioGroup': + case 'checkBoxList': + case 'multi_selection_image': + cy.get(`#${valueId}`).click({ force: true }); + break; + case 'single_selection_image': + const labelId = `cx-configurator--label--${attributeName}--${valueName}`; + cy.log('labelId: ' + labelId); + cy.get(`#${labelId}`) + .click({ force: true }) + .then(() => { + checkUpdatingMessageNotDisplayed(); + cy.get(`#${valueId}-input`).should('be.checked'); + }); + break; + case 'dropdown': + cy.get(`#${attributeId} ng-select`).ngSelect(valueName); + break; + case 'input': + cy.get(`#${valueId}`).clear().type(value); + } + + checkUpdatingMessageNotDisplayed(); +} + +/** + * Verifies whether the image value is selected. + * + * @param {string} attributeName - Attribute name + * @param {string} uiType - UI type + * @param {string} valueName - Value name + */ +export function checkImageSelected( + uiType: string, + attributeName: string, + valueName: string +): void { + const attributeId = getAttributeId(attributeName, uiType); + const valueId = `${attributeId}--${valueName}-input`; + cy.log('valueId: ' + valueId); + cy.get(`#${valueId}`).should('be.checked'); +} + +/** + * Verifies whether the image value is not selected. + * + * @param {string} uiType - UI type + * @param {string} attributeName - Attribute name + * @param {string} valueName - Value name + */ +export function checkImageNotSelected( + uiType: string, + attributeName: string, + valueName: string +): void { + const attributeId = getAttributeId(attributeName, uiType); + const valueId = `${attributeId}--${valueName}-input`; + cy.get(`#${valueId}`).should('not.be.checked'); +} + +/** + * Verifies whether a corresponding UI type is selected. + * + * @param {string} uiType - UI type + * @param {string} attributeName - Attribute name + * @param {string} valueName - Value name + */ +export function checkValueSelected( + uiType: string, + attributeName: string, + valueName: string +): void { + const attributeId = getAttributeId(attributeName, uiType); + const valueId = `${attributeId}--${valueName}`; + cy.get(`#${valueId}`).should('be.checked'); +} + +/** + * Verifies whether a corresponding UI type not selected. + * + * param {string} uiType - UI type + * @param {string} attributeName - Attribute name + * @param {string} valueName - Value name + */ +export function checkValueNotSelected( + uiType: string, + attributeName: string, + valueName: string +) { + const attributeId = getAttributeId(attributeName, uiType); + const valueId = `${attributeId}--${valueName}`; + cy.get(`#${valueId}`).should('not.be.checked'); +} + +/** + * Verifies whether the conflict detected under the attribute name is displayed. + * + * @param {string} attributeName - Attribute name + */ +export function checkConflictDetectedMsgDisplayed(attributeName: string): void { + const parent = cy.get(conflictDetectedMsgSelector).parent(); + const attributeId = this.getAttributeLabelId(attributeName); + parent.children(`#${attributeId}`).should('be.visible'); +} + +/** + * Verifies whether the conflict detected under the attribute name is not displayed. + * + * @param {string} attributeName - Attribute name + */ +export function checkConflictDetectedMsgNotDisplayed( + attributeName: string +): void { + const attributeId = this.getAttributeLabelId(attributeName); + cy.get(`#${attributeId}`).next().should('not.exist'); +} + +/** + * Verifies whether the conflict description is displayed. + * + * @param {string} description - Conflict description + */ +export function checkConflictDescriptionDisplayed(description: string): void { + cy.get('cx-configurator-conflict-description').should(($div) => { + expect($div).to.contain(description); + }); +} + +/** + * Verifies whether the conflict header group is displayed. + */ +function checkConflictHeaderGroupDisplayed(): void { + cy.get(conflictHeaderGroupSelector).should('be.visible'); +} + +/** + * Verifies whether the conflict header group is not displayed. + */ +function checkConflictHeaderGroupNotDisplayed(): void { + cy.get(conflictHeaderGroupSelector).should('not.exist'); +} + +/** + * Verifies whether the expected number of conflicts is accurate. + * + * @param {number} numberOfConflicts - Expected number of conflicts + */ +function verifyNumberOfConflicts(numberOfConflicts: number): void { + cy.get('cx-configurator-group-menu .conflictNumberIndicator').contains( + '(' + numberOfConflicts.toString() + ')' + ); +} + +/** + * Selects a conflicting value, namely selects a value. + * Then verifies whether the conflict detected message under the attribute name is displayed, + * The conflict header group in the group menu is displayed and + * Finally verifies whether the expected number of conflicts is accurate. + * + * @param {string} attributeName - Attribute name + * @param {string} uiType - UI type + * @param {string} valueName - Value name + * @param {number} numberOfConflicts - Expected number of conflicts + */ +export function selectConflictingValue( + attributeName: string, + uiType: string, + valueName: string, + numberOfConflicts: number +): void { + this.selectAttribute(attributeName, uiType, valueName); + this.checkConflictDetectedMsgDisplayed(attributeName); + checkConflictHeaderGroupDisplayed(); + verifyNumberOfConflicts(numberOfConflicts); +} + +/** + * Deselects a conflicting value, namely deselects a value. + * Then verifies whether the conflict detected message under the attribute name is not displayed anymore and + * the conflict header group in the group menu is not displayed either. + * + * @param {string} attributeName - Attribute name + * @param {string} uiType - UI type + * @param {string} valueName - Value name + */ +export function deselectConflictingValue( + attributeName: string, + uiType: string, + valueName: string +): void { + this.selectAttribute(attributeName, uiType, valueName); + this.checkConflictDetectedMsgNotDisplayed(attributeName); + checkConflictHeaderGroupNotDisplayed(); +} + +/** + * Verifies whether the issues banner is displayed. + * + * @param element - HTML element + * @param {number} numberOfIssues - Expected number of conflicts + */ +export function checkNotificationBanner( + element, + numberOfIssues?: number +): void { + const resolveIssuesText = 'must be resolved before checkout. Resolve Issues'; + element + .get('.cx-error-msg') + .first() + .invoke('text') + .then((text) => { + expect(text).contains(resolveIssuesText); + if (numberOfIssues > 1) { + const issues = text.replace(resolveIssuesText, '').trim(); + expect(issues).match(/^[0-9]/); + expect(issues).eq(numberOfIssues.toString()); + } + }); +} + +/** + * Verifies whether the issues banner is displayed in the cart for a certain cart item. + * + * @param {number} cartItemIndex - Index of cart item + * @param {number} numberOfIssues - Expected number of conflicts + */ +export function verifyNotificationBannerInCart( + cartItemIndex: number, + numberOfIssues?: number +): void { + const element = cy + .get('cx-cart-item-list .cx-item-list-row') + .eq(cartItemIndex) + .find('cx-configurator-issues-notification'); + + if (numberOfIssues) { + checkNotificationBanner(element, numberOfIssues); + } else { + element.should('not.be.visible'); + } +} + +/** + * Clicks on 'Resolve Issues' link in the cart. + * + * @param {number} cartItemIndex - Index of cart item + */ +export function clickOnResolveIssuesLinkInCart(cartItemIndex: number): void { + cy.get('cx-cart-item-list .cx-item-list-row') + .eq(cartItemIndex) + .find('cx-configurator-issues-notification') + .within(() => { + cy.get(resolveIssuesLinkSelector) + .click() + .then(() => { + cy.location('pathname').should('contain', ' /cartEntry/entityKey/'); + }); + }); +} + +/** + * Verifies whether the group menu is displayed. + */ +export function checkGroupMenuDisplayed(): void { + cy.get('cx-configurator-group-menu').should('be.visible'); +} + +/** + * Verifies whether the group title is displayed. + */ +function checkGroupTitleDisplayed(): void { + checkUpdatingMessageNotDisplayed(); + cy.get('cx-configurator-group-title').should('be.visible'); +} + +/** + * Verifies whether the group form is displayed. + */ +function checkGroupFormDisplayed(): void { + checkUpdatingMessageNotDisplayed(); + cy.get('cx-configurator-form').should('be.visible'); +} + +/** + * Verifies whether the 'previous' and 'next' buttons are displayed. + */ +function checkPreviousAndNextBtnsDispalyed(): void { + checkUpdatingMessageNotDisplayed(); + cy.get('cx-configurator-previous-next-buttons').should('be.visible'); +} + +/** + * Verifies whether the price summary is displayed. + */ +function checkPriceSummaryDisplayed(): void { + checkUpdatingMessageNotDisplayed(); + cy.get('cx-configurator-price-summary').should('be.visible'); +} + +/** + * Verifies whether the 'add to cart' button is displayed. + */ +function checkAddToCartBtnDisplayed(): void { + checkUpdatingMessageNotDisplayed(); + cy.get('cx-configurator-add-to-cart-button').should('be.visible'); +} + +/** + * Verifies whether the group menu is not displayed. + */ +export function checkConfigProductTitleDisplayed(): void { + cy.get('a:contains("show more")').should('be.visible'); +} + +/** + * Verifies whether the Add To Cart Button component is displayed. + */ +export function checkConfigAddToCartBtnDisplayed(): void { + cy.get('.cx-configurator-add-to-cart-btn').should('be.visible'); +} + +/** + * Verifies whether the overview content is displayed. + */ +export function checkOverviewContentDisplayed(): void { + cy.get('.cx-configurator-group-attribute').should('be.visible'); +} + +/** + * Verifies whether the category navigation is displayed. + */ +export function checkCategoryNavigationDisplayed(): void { + cy.get('cx-category-navigation').should('be.visible'); +} + +/** + * Verifies whether the category navigation is displayed. + */ +export function checkCategoryNavigationNotDisplayed(): void { + cy.get('cx-category-navigation').should('not.be.visible'); +} + +/** + * Verifies the accuracy of the formatted price. + * + * @param {string} formattedPrice - Formatted price + */ +export function checkTotalPrice(formattedPrice: string): void { + cy.get( + 'cx-configurator-price-summary div.cx-total-price div.cx-amount' + ).should(($div) => { + expect($div).to.contain(formattedPrice); + }); +} + +/** + * Navigates to the overview page via the overview tab. + */ +export function navigateToOverviewPage(): void { + cy.get('cx-configurator-tab-bar a:contains("Overview")').click({ + force: true, + }); +} + +/** + * Clicks on the group via its index in the group menu. + * + * @param {number} groupIndex - Group index + */ +function clickOnGroupByGroupIndex(groupIndex: number): void { + cy.get('cx-configurator-group-menu ul>li.cx-menu-item') + .not('.cx-menu-conflict') + .eq(groupIndex) + .children('a') + .click({ force: true }); +} + +/** + * Clicks on the group via its index in the group menu. + * + * @param {number} groupIndex - Group index + */ +export function clickOnGroup(groupIndex: number): void { + cy.get('cx-configurator-group-menu ul>li.cx-menu-item') + .not('.cx-menu-conflict') + .eq(groupIndex) + .within(() => { + cy.get('a') + .children() + .within(() => { + cy.get('div.subGroupIndicator').within(($list) => { + cy.log('$list.children().length: ' + $list.children().length); + cy.wrap($list.children().length).as('subGroupIndicator'); + }); + }); + }); + + cy.get('@subGroupIndicator').then((subGroupIndicator) => { + cy.log('subGroupIndicator: ' + subGroupIndicator); + if (!subGroupIndicator) { + clickOnGroupByGroupIndex(groupIndex); + } else { + clickOnGroupByGroupIndex(groupIndex); + clickOnGroupByGroupIndex(0); + } + }); +} + +/** + * Clicks the group menu. + */ +export function clickHamburger(): void { + cy.get('cx-hamburger-menu [aria-label="Menu"]') + .click() + .then(() => { + checkUpdatingMessageNotDisplayed(); + }); +} + +/** + * Verifies whether the group menu is displayed. + */ +export function checkHamburgerDisplayed(): void { + cy.get('cx-hamburger-menu [aria-label="Menu"]').should('be.visible'); +} + +/** + * Clicks on the 'Add to cart' button. + */ +export function clickAddToCartBtn(): void { + cy.get(addToCartButtonSelector) + .click() + .then(() => { + cy.location('pathname').should('contain', 'cartEntry/entityKey/'); + checkGlobalMessageNotDisplayed(); + }); +} + +export function registerCartRefreshRoute() { + cy.intercept( + 'GET', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/users/*/carts/*?fields=*&lang=en&curr=USD` + ).as('refresh_cart'); +} + +export function closeAddedToCartDialog() { + cy.get('cx-added-to-cart-dialog [aria-label="Close"]').click({ force: true }); +} + +/** + * Clicks on 'Add to cart' on the product details page. + */ +export function clickOnAddToCartBtnOnPD(): void { + registerCartRefreshRoute(); + + cy.get('cx-add-to-cart button[type=submit]').first().click({ force: true }); + cy.wait('@refresh_cart'); +} + +/** + * Clicks on 'View Cart' on the product details page. + */ +export function clickOnViewCartBtnOnPD(): void { + cy.get('div.cx-dialog-buttons a.btn-primary') + .contains('view cart') + .click() + .then(() => { + cy.location('pathname').should('contain', '/cart'); + cy.get('h1').contains('Your Shopping Cart').should('be.visible'); + cy.get('cx-cart-details').should('be.visible'); + }); +} + +/** + * Clicks on 'Proceed to Checkout' on the product details page. + */ +export function clickOnProceedToCheckoutBtnOnPD(): void { + cy.get('div.cx-dialog-buttons a.btn-secondary') + .contains('proceed to checkout') + .click() + .then(() => { + cy.location('pathname').should('contain', '/checkout/shipping-address'); + cy.get('.cx-checkout-title').should('contain', 'Shipping Address'); + cy.get('cx-shipping-address').should('be.visible'); + }); +} + +/** + * Navigates to the order details page. + */ +export function navigateToOrderDetails(): void { + cy.log('Navigate to order detail page'); + // Verify whether the ordered product is displayed in the order list + cy.get('cx-cart-item-list cx-configure-cart-entry a') + .first() + .click() + .then(() => { + cy.get('cx-configurator-overview-form').should('be.visible'); + }); +} + +/** + * Navigates to the oder history page. + * + * @return {Chainable} - New order history window + */ +export function goToOrderHistory(): Chainable { + cy.log('Navigate to order history'); + return cy.visit('/electronics-spa/en/USD/my-account/orders').then(() => { + cy.get('cx-order-history h3').should('contain', 'Order history'); + }); +} + +/** + * Verifies whether the searched order exists in the order history and + * sets the '@isFound' alias accordingly. + * + * @param {string} orderNumber - Order number + */ +function searchForOrder(orderNumber: string): void { + cy.get('cx-order-history') + .get('td.cx-order-history-code a.cx-order-history-value') + .each((elem) => { + const order = elem.text().trim(); + cy.log('order number: ' + order); + cy.log('searched order number: ' + orderNumber); + if (order === orderNumber) { + cy.wrap(true).as('isFound'); + return false; + } else { + cy.wrap(false).as('isFound'); + } + }); +} + +/** + * Selects the order by the oder number alias. + */ +export function selectOrderByOrderNumberAlias(): void { + cy.get('@orderNumber').then((orderNumber) => { + cy.log('Searched order number: ' + orderNumber); + // To refresh the order history content, navigate to the home page and back to the order history + cy.log('Navigate to home page'); + navigation.visitHomePage({}); + this.goToOrderHistory(); + + // Verify whether the searched order exists + searchForOrder(orderNumber.toString()); + cy.get('@isFound').then((isFound) => { + let found = isFound ? ' ' : ' not '; + cy.log("Order with number '" + orderNumber + "' is" + found + 'found'); + if (!isFound) { + cy.waitForOrderToBePlacedRequest( + 'electronics-spa', + 'USD', + orderNumber.toString() + ); + + searchForOrder(orderNumber.toString()); + cy.get('@isFound').then((isOFound) => { + found = isOFound ? ' ' : ' not '; + cy.log( + "Order with number '" + orderNumber + "' is" + found + 'found' + ); + if (!isFound) { + // To refresh the order history content, navigate to the home page and back to the order history + cy.log('Navigate to home page'); + navigation.visitHomePage({}); + this.goToOrderHistory(); + } + }); + } + // Navigate to the order details page of the searched order + cy.get( + 'cx-order-history a.cx-order-history-value:contains(' + + `${orderNumber}` + + ')' + ) + .click() + .then(() => { + navigateToOrderDetails(); + }); + }); + }); +} + +/** + * Defines the order number alias. + */ +export function defineOrderNumberAlias(): void { + const orderConfirmationText = 'Confirmation of Order:'; + + cy.get('cx-order-confirmation-thank-you-message h1.cx-page-title') + .first() + .invoke('text') + .then((text) => { + expect(text).contains(orderConfirmationText); + const orderNumber = text.replace(orderConfirmationText, '').trim(); + expect(orderNumber).match(/^[0-9]+$/); + cy.wrap(orderNumber).as('orderNumber'); + }); +} + +/** + * Conducts the checkout. + */ +export function checkout(): void { + cy.log('Complete checkout process'); + cy.get('.cx-checkout-title').should('contain', 'Shipping Address'); + cy.log('Fulfill shipping address form'); + fillShippingAddress(shippingAddressData, false); + + cy.log("Navigate to the next step 'Delivery mode' tab"); + cy.get('button.btn-primary') + .contains('Continue') + .click() + .then(() => { + cy.location('pathname').should('contain', '/checkout/delivery-mode'); + cy.get('.cx-checkout-title').should('contain', 'Shipping Method'); + cy.get('cx-delivery-mode').should('be.visible'); + }); + + cy.log("Navigate to the next step 'Payment details' tab"); + cy.get('button.btn-primary') + .contains('Continue') + .click() + .then(() => { + cy.location('pathname').should('contain', '/checkout/payment-details'); + cy.get('.cx-checkout-title').should('contain', 'Payment'); + cy.get('cx-payment-method').should('be.visible'); + }); + + cy.log('Fulfill payment details form'); + fillPaymentDetails(paymentDetailsData, billingAddress); + + cy.log("Check 'Terms & Conditions'"); + cy.get('input[formcontrolname="termsAndConditions"]') + .check() + .then(() => { + cy.get('cx-place-order form').should('have.class', 'ng-valid'); + }); + + cy.log('Place order'); + cy.get('cx-place-order button.btn-primary') + .click() + .then(() => { + cy.location('pathname').should('contain', '/order-confirmation'); + cy.get('cx-breadcrumb').should('contain', 'Order Confirmation'); + }); + + cy.log('Define order number alias'); + defineOrderNumberAlias(); +} diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/product-search.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/product-search.ts index 769932b0a66..2028e2ed2ed 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/product-search.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/product-search.ts @@ -41,6 +41,10 @@ export function clickSearchIcon() { cy.get('cx-searchbox cx-icon[aria-label="search"]').click({ force: true }); } +export function searchForProduct(product: string) { + cy.get('cx-searchbox input').type(`${product}{enter}`); +} + export function assertFirstProduct() { cy.get(productNameSelector).first().invoke('text').should('match', /\w+/); } diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/textfield-configuration.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/textfield-configuration.ts new file mode 100644 index 00000000000..87f7bfe78cf --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/textfield-configuration.ts @@ -0,0 +1,198 @@ +import * as cart from './cart'; +import Chainable = Cypress.Chainable; + +const addToCartButtonSelector = + 'cx-configurator-textfield-add-to-cart-button button'; + +/** + * Navigates to the product configuration page. + * + * @param {string} shopName - shop name + * @param {string} productId - Product ID + * @return {Chainable} - New configuration window + */ +export function goToConfigurationPage( + shopName: string, + productId: string +): Chainable { + const location = `/${shopName}/en/USD/configure/textfield/product/entityKey/${productId}`; + return cy.visit(location).then(() => { + cy.log("Path name should contain: '" + location + "'"); + cy.location('pathname').should('contain', location); + }); +} + +/** + * Navigates to the product configuration page. + * + * @param {string} shopName - shop name + * @param {string} productId - Product ID + * @return {Chainable} - New configuration window + */ +export function goToProductDetailsPage( + shopName: string, + productId: string +): Chainable { + const location = `${shopName}/en/USD/product/${productId}/${productId}`; + return cy.visit(location).then(() => { + cy.log("Path name should contain: '" + location + "'"); + cy.location('pathname').should('contain', location); + }); +} + +/** + * Clicks on 'Configure' button. + */ +export function clickOnConfigureButton(): void { + cy.log("Click on 'Configure' button"); + cy.get('cx-configure-product a') + .click() + .then(() => { + checkConfigurationPageIsDisplayed(); + }); +} + +/** + * Clicks on the 'Edit Configuration' link in cart for a certain cart item. + * + * @param {number} cartItemIndex - Index of cart item + */ +export function clickOnEditConfigurationLink(cartItemIndex: number): void { + cy.log("Click on 'Edit Configuration' link"); + cy.get('cx-cart-item-list .cx-item-list-row') + .eq(cartItemIndex) + .find('cx-configure-cart-entry') + .within(() => { + cy.get('a:contains("Edit")') + .click() + .then(() => { + cy.log("Path name should contain: '/cartEntry/entityKey/'"); + cy.location('pathname').should('contain', '/cartEntry/entityKey/'); + }); + }); +} + +/** + * Verifies whether the configuration page is visible. + */ +export function checkConfigurationPageIsDisplayed(): void { + cy.log('Verify whether the textfield configuration page is displayed'); + cy.get('cx-configurator-textfield-form').should('be.visible'); +} + +/** + * Verifies whether the attribute is displayed. + * + * @param {string} attributeName - Attribute name + */ +export function checkAttributeDisplayed(attributeName: string): void { + const attributeId = getAttributeId(attributeName); + cy.log("Verify whether attribute ID '" + attributeId + "' is visible"); + cy.get(`#${attributeId}`).should('be.visible'); +} + +/** + * Retrieves attribute ID. + * + * @param {string} attributeName - Attribute name + * @return {string} - Attribute ID + */ +export function getAttributeId(attributeName: string): string { + const trimmedName = attributeName.replace(/\s/g, ''); + cy.log("Trimmed name: '" + trimmedName); + return `cx-configurator-textfieldlabel${trimmedName}`; +} + +/** + * Selects value of the corresponding attribute. + * + * @param {string} attributeName - Attribute name + * @param {string} value - Value name + */ +export function selectAttribute(attributeName: string, value?: string): void { + const attributeId = getAttributeId(attributeName); + cy.get(`#${attributeId}`) + .next('.form-group') + .children('input') + .then(($element) => { + cy.log('Empty out the input field'); + $element.empty(); + cy.log("Enter new value: '" + value + "' into the input field"); + $element.val(value); + }); +} + +/** + * Clicks 'Add to Cart' button. + * + * @param {string} shopName - shop name + */ +export function clickAddToCartButton(shopName: string): void { + const location = `/${shopName}/en/USD/cart`; + cy.log("Clicks 'Add to Cart' button"); + cy.get(addToCartButtonSelector) + .click() + .then(() => { + cy.location('pathname').should('contain', location); + cy.get('h1').contains('Your Shopping Cart').should('be.visible'); + }); +} + +/** + * Clicks 'Add to Cart' button on the product details page. + */ +export function clickOnAddToCartBtnOnPD(): void { + cy.log("Clicks 'Add to Cart' button on the product details page"); + cy.get('cx-add-to-cart button.btn-primary') + .contains('Add to cart') + .click() + .then(() => { + cy.get('cx-added-to-cart-dialog').should('be.visible'); + cy.get('div.cx-dialog-body').should('be.visible'); + cy.get('div.cx-dialog-buttons a.btn-primary') + .contains('view cart') + .should('be.visible'); + cy.get('div.cx-dialog-buttons a.btn-secondary') + .contains('proceed to checkout') + .should('be.visible'); + }); +} + +/** + * Clicks on 'View Cart' on the product details page. + */ +export function clickOnViewCartBtnOnPD(): void { + cy.log("Clicks on 'View Cart' on the product details page"); + cy.get('div.cx-dialog-buttons a.btn-primary') + .contains('view cart') + .click() + .then(() => { + cy.log("Verify whether 'Your Shopping Cart' is visible"); + cy.get('h1').contains('Your Shopping Cart').should('be.visible'); + cy.log("Verify whether 'cx-cart-details' is visible"); + cy.get('cx-cart-details').should('be.visible'); + }); +} + +/** + * Verifies whether the cart contains the product. + * + * @param {string} productId - ProductID + */ +export function checkTextfieldProductInCart(productId: string): void { + cy.log('Verifies whether the cart contains the product'); + cy.get('cx-cart-item-list').contains(productId).should('be.visible'); +} + +/** + * Add a product to the cart. Verifies whether the cart is not empty and + * contains the product. + * + * @param {string} shopName - shop name + * @param {string} productId - Product ID + */ +export function addToCartAndVerify(shopName: string, productId: string): void { + this.clickAddToCartButton(shopName); + cart.verifyCartNotEmpty(); + this.checkTextfieldProductInCart(productId); +} diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/accessibility/product-configuration-tabbing.flaky-e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/accessibility/product-configuration-tabbing.flaky-e2e-spec.ts new file mode 100644 index 00000000000..22aca9b9c27 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/integration/accessibility/product-configuration-tabbing.flaky-e2e-spec.ts @@ -0,0 +1,116 @@ +import { verifyTabbingOrder } from '../../helpers/accessibility/tabbing-order'; +import { tabbingOrderConfig as tabConfig } from '../../helpers/accessibility/tabbing-order.config'; +import * as configuration from '../../helpers/product-configuration'; +import * as configurationOverview from '../../helpers/product-configuration-overview'; +/** + * This suite is marked as flaky due to performance (synchronization) issues on + * https://spartacus-devci767.eastus.cloudapp.azure.com:9002 that we analyze in + * https://cxjira.sap.com/browse/TIGER-7252 + */ + +const electronicsShop = 'electronics-spa'; +const testProduct = 'CONF_CAMERA_SL'; + +const containerSelectorConfigForm = 'main'; +const containerSelectorOverviewForm = 'main'; + +// List of attributes +const CAMERA_MODE = 'CAMERA_MODE'; +const CAMERA_COLOR = 'CAMERA_COLOR'; +const CAMERA_SD_CARD = 'CAMERA_SD_CARD'; +// attribute types +const RADIO_GROUP = 'radioGroup'; +const CHECKBOX_LIST = 'checkBoxList'; +// attribute values +const CAMERA_MODE_PROFESSIONAL = 'P'; +const CAMERA_COLOR_METALLIC = 'METALLIC'; +const CAMERA_SD_CARD_SDXC = 'SDXC'; +// group names +const SPECIFICATION = 'Specification'; + +context('Product Configuration', () => { + beforeEach(() => { + cy.visit('/'); + }); + + describe('Product Config Tabbing', () => { + it('should allow to navigate with tab key', () => { + configuration.goToConfigurationPage(electronicsShop, testProduct); + + verifyTabbingOrder( + containerSelectorConfigForm, + tabConfig.productConfigurationPage + ); + + configuration.selectAttribute( + CAMERA_MODE, + RADIO_GROUP, + CAMERA_MODE_PROFESSIONAL + ); + configuration.navigateToOverviewPage(); + + configuration.checkGlobalMessageNotDisplayed(); + configuration.checkUpdatingMessageNotDisplayed(); + configurationOverview.checkConfigOverviewPageDisplayed(); + verifyTabbingOrder( + containerSelectorOverviewForm, + tabConfig.productConfigurationOverview + ); + }); + }); + + describe('Product Config Keep Focus', () => { + it('should keep focus after selection', () => { + cy.server(); + + cy.route( + 'PATCH', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/ccpconfigurator/*` + ).as('updateConfig'); + + cy.route( + 'GET', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/ccpconfigurator/*/pricing*` + ).as('priceUpdate'); + + configuration.goToConfigurationPage(electronicsShop, testProduct); + + cy.wait('@priceUpdate'); + + configuration.selectAttribute( + CAMERA_COLOR, + RADIO_GROUP, + CAMERA_COLOR_METALLIC + ); + + cy.wait('@updateConfig'); + cy.wait('@priceUpdate'); + + configuration.checkFocus( + CAMERA_COLOR, + RADIO_GROUP, + CAMERA_COLOR_METALLIC + ); + + configuration.clickOnNextBtn(SPECIFICATION); + configuration.selectAttribute( + CAMERA_SD_CARD, + CHECKBOX_LIST, + CAMERA_SD_CARD_SDXC + ); + + cy.wait('@updateConfig'); + cy.wait('@priceUpdate'); + + configuration.checkFocus( + CAMERA_SD_CARD, + CHECKBOX_LIST, + CAMERA_SD_CARD_SDXC + ); + }); + }); +}); diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/mobile/product-configuration-mobile.flaky-e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/mobile/product-configuration-mobile.flaky-e2e-spec.ts new file mode 100644 index 00000000000..d30ff3d7d66 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/integration/mobile/product-configuration-mobile.flaky-e2e-spec.ts @@ -0,0 +1,39 @@ +import * as configuration from '../../helpers/product-configuration'; +import { formats } from '../../sample-data/viewports'; + +/** + * This suite is marked as flaky due to performance (synchronization) issues on + * https://spartacus-devci767.eastus.cloudapp.azure.com:9002 that we analyze in + * https://cxjira.sap.com/browse/TIGER-7252 + */ + +const electronicsShop = 'electronics-spa'; +const testProduct = 'CONF_CAMERA_SL'; + +// UI types +const radioGroup = 'radioGroup'; + +const CAMERA_DISPLAY = 'CAMERA_DISPLAY'; +const CAMERA_MODE = 'CAMERA_MODE'; + +context('Product Configuration', () => { + beforeEach(() => { + cy.visit('/'); + }); + + describe('Group Handling', () => { + it('should navigate using the group menu in mobile resolution', () => { + cy.window().then((win) => win.sessionStorage.clear()); + cy.viewport(formats.mobile.width, formats.mobile.height); + configuration.goToConfigurationPage(electronicsShop, testProduct); + configuration.checkHamburgerDisplayed(); + configuration.checkAttributeDisplayed(CAMERA_MODE, radioGroup); + + configuration.clickHamburger(); + configuration.checkGroupMenuDisplayed(); + + configuration.clickOnGroup(2); + configuration.checkAttributeDisplayed(CAMERA_DISPLAY, radioGroup); + }); + }); +}); diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-configuration-cart.flaky-e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-configuration-cart.flaky-e2e-spec.ts new file mode 100644 index 00000000000..bc4f0909b52 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-configuration-cart.flaky-e2e-spec.ts @@ -0,0 +1,189 @@ +import * as cart from '../../helpers/cart'; +import * as login from '../../helpers/login'; +import * as configuration from '../../helpers/product-configuration'; +import * as configurationOverview from '../../helpers/product-configuration-overview'; +import * as productSearch from '../../helpers/product-search'; + +/** + * This suite is marked as flaky due to performance (synchronization) issues on + * https://spartacus-devci767.eastus.cloudapp.azure.com:9002 that we analyze in + * https://cxjira.sap.com/browse/TIGER-7252 + */ + +const electronicsShop = 'electronics-spa'; +const testProductMultiLevel = 'CONF_HOME_THEATER_ML'; + +// UI types +const radioGroup = 'radioGroup'; + +// Group Status +const WARNING = 'WARNING'; + +// List of groups +const GENERAL = 'General'; +const VIDEO_SYSTEM = 'Video System'; +const SOURCE_COMPONENTS = 'Source Components'; +const PROJECTOR = 'Projector'; +const PROJECTOR_SCREEN = 'Projection Screen'; +const FRONT_SPEAKERS = 'Front Speakers'; +const CENTER_SPEAKER = 'Center Speaker'; +const REAR_SPEAKER = 'Rear Speakers'; +const SUBWOOFER = 'Subwoofer'; + +// List of attributes +const PROJECTOR_TYPE = 'PROJECTOR_TYPE'; +const GAMING_CONSOLE = 'GAMING_CONSOLE'; + +// List of attribute values +const PROJECTOR_LCD = 'PROJECTOR_LCD'; +const GAMING_CONSOLE_YES = 'GAMING_CONSOLE_YES'; +const GAMING_CONSOLE_NO = 'GAMING_CONSOLE_NO'; + +// List of conflict groups +const CONFLICT_FOR_GAMING_CONSOLE = 'Conflict for Gaming Console'; + +// Conflict message +const Conflict_msg_gaming_console = + 'Gaming console cannot be selected with LCD projector'; + +context('Product Configuration', () => { + beforeEach(() => { + cy.visit('/'); + }); + + describe('Navigate to Product Configuration Page', () => { + it('should be able to navigate from the cart', () => { + configuration.goToConfigurationPage( + electronicsShop, + testProductMultiLevel + ); + configuration.clickAddToCartBtn(); + configuration.goToCart(electronicsShop); + //We assume only one product is in the cart + configuration.clickOnEditConfigurationLink(0); + }); + + it('should be able to navigate from the cart after adding product directly to the cart', () => { + cy.server(); + cy.route( + 'GET', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/products/suggestions?term=CONF_HOME_THEATER_ML*` + ).as('productSearch'); + productSearch.searchForProduct(testProductMultiLevel); + cy.wait('@productSearch'); + configuration.clickOnAddToCartBtnOnPD(); + configuration.clickOnViewCartBtnOnPD(); + cart.verifyCartNotEmpty(); + configuration.clickOnEditConfigurationLink(0); + }); + }); + + describe('Conflict Solver', () => { + it('should support the conflict solving process', () => { + cy.server(); + cy.route( + 'PATCH', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/ccpconfigurator/*` + ).as('updateConfig'); + configuration.goToConfigurationPage( + electronicsShop, + testProductMultiLevel + ); + configuration.clickOnNextBtn(PROJECTOR); + configuration.selectAttribute(PROJECTOR_TYPE, radioGroup, PROJECTOR_LCD); + cy.wait('@updateConfig'); + configuration.clickOnPreviousBtn(GENERAL); + configuration.clickOnGroup(3); + + configuration.selectConflictingValue( + GAMING_CONSOLE, + radioGroup, + GAMING_CONSOLE_YES, + 1 + ); + cy.wait('@updateConfig'); + configuration.checkStatusIconDisplayed(SOURCE_COMPONENTS, WARNING); + configuration.checkStatusIconDisplayed(VIDEO_SYSTEM, WARNING); + configuration.deselectConflictingValue( + GAMING_CONSOLE, + radioGroup, + GAMING_CONSOLE_NO + ); + cy.wait('@updateConfig'); + configuration.checkStatusIconNotDisplayed(SOURCE_COMPONENTS); + configuration.checkStatusIconNotDisplayed(VIDEO_SYSTEM); + configuration.selectConflictingValue( + GAMING_CONSOLE, + radioGroup, + GAMING_CONSOLE_YES, + 1 + ); + cy.wait('@updateConfig'); + configuration.clickOnPreviousBtn(SUBWOOFER); + configuration.clickOnPreviousBtn(REAR_SPEAKER); + configuration.clickOnPreviousBtn(CENTER_SPEAKER); + configuration.clickOnPreviousBtn(FRONT_SPEAKERS); + configuration.clickOnPreviousBtn(PROJECTOR_SCREEN); + configuration.clickOnPreviousBtn(PROJECTOR); + configuration.checkConflictDetectedMsgDisplayed(PROJECTOR_TYPE); + configuration.clickOnPreviousBtn(GENERAL); + configuration.clickOnPreviousBtn(CONFLICT_FOR_GAMING_CONSOLE); + configuration.checkConflictDescriptionDisplayed( + Conflict_msg_gaming_console + ); + configuration.clickOnNextBtn(GENERAL); + configuration.checkStatusIconDisplayed(SOURCE_COMPONENTS, WARNING); + configuration.checkStatusIconDisplayed(VIDEO_SYSTEM, WARNING); + configurationOverview.registerConfigurationOvOCC(); + configuration.clickAddToCartBtn(); + // Navigate to Overview page and verify whether the resolve issues banner is displayed and how many issues are there + configurationOverview.verifyNotificationBannerOnOP(1); + // Navigate to cart and verify whether the the resolve issues banner is displayed and how many issues are there + configurationOverview.clickContinueToCartBtnOnOP(); + configuration.verifyNotificationBannerInCart(0, 1); + // Navigate back to the configuration page + configuration.clickOnEditConfigurationLink(0); + // Navigate to Overview page and back to configuration via 'Resolve issues' link + configuration.clickAddToCartBtn(); + // Click 'Resolve issues' link in the banner and navigate back to the configuration + configurationOverview.clickOnResolveIssuesLinkOnOP(); + configuration.checkConflictDescriptionDisplayed( + Conflict_msg_gaming_console + ); + configuration.clickOnNextBtn(GENERAL); + // Navigate back to the configuration page and deselect conflicting value + configuration.clickOnGroup(3); + configuration.deselectConflictingValue( + GAMING_CONSOLE, + radioGroup, + GAMING_CONSOLE_NO + ); + //Click 'Add to cart' and verify whether the resolve issues banner is not displayed anymore + configurationOverview.registerConfigurationOvOCC(); + configuration.clickAddToCartBtn(); + configurationOverview.verifyNotificationBannerOnOP(); + // Click 'Continue to cart' and verify whether there is a resolve issues banner in the cart entry list + configurationOverview.clickContinueToCartBtnOnOP(); + configuration.verifyNotificationBannerInCart(0); + }); + }); + + describe('Configuration process', () => { + it('should support the product configuration aspect in product search, cart, checkout and order history', () => { + login.registerUser(); + login.loginUser(); + productSearch.searchForProduct(testProductMultiLevel); + configuration.clickOnAddToCartBtnOnPD(); + configuration.clickOnProceedToCheckoutBtnOnPD(); + configuration.checkout(); + configuration.navigateToOrderDetails(); + //don't check the order history aspect because this part is flaky + //configuration.selectOrderByOrderNumberAlias(); + login.signOutUser(); + }); + }); +}); diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-configuration-interactive.flaky-e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-configuration-interactive.flaky-e2e-spec.ts new file mode 100644 index 00000000000..e851aa0b7be --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-configuration-interactive.flaky-e2e-spec.ts @@ -0,0 +1,303 @@ +import * as configuration from '../../helpers/product-configuration'; +import * as configurationOverview from '../../helpers/product-configuration-overview'; +import * as productSearch from '../../helpers/product-search'; + +/** + * This suite is marked as flaky due to performance (synchronization) issues on + * https://spartacus-devci767.eastus.cloudapp.azure.com:9002 that we analyze in + * https://cxjira.sap.com/browse/TIGER-7252 + */ + +const electronicsShop = 'electronics-spa'; +const testProduct = 'CONF_CAMERA_SL'; +const testProductMultiLevel = 'CONF_HOME_THEATER_ML'; + +// UI types +const radioGroup = 'radioGroup'; +const single_selection_image = 'single_selection_image'; +const checkBoxList = 'checkBoxList'; + +// Group Status +const ERROR = 'ERROR'; +const COMPLETE = 'COMPLETE'; + +// List of groups +const BASICS = 'Basics'; +const SPECIFICATION = 'Specification'; +const DISPLAY = 'Display'; +const LENS = 'Lens'; +const OPTIONS = 'Options'; +const GENERAL = 'General'; +const VIDEO_SYSTEM = 'Video System'; +const AUDIO_SYSTEM = 'Audio System'; +const SOURCE_COMPONENTS = 'Source Components'; +const PROJECTOR = 'Projector'; +const FRONT_SPEAKERS = 'Front Speakers'; +const CENTER_SPEAKER = 'Center Speaker'; +const REAR_SPEAKER = 'Rear Speakers'; +const SUBWOOFER = 'Subwoofer'; +const FLAT_PANEL = 'Flat-panel TV'; + +// List of attributes +const COLOUR_HT = 'COLOUR_HT'; +const CAMERA_PIXELS = 'CAMERA_PIXELS'; +const CAMERA_DISPLAY = 'CAMERA_DISPLAY'; +const CAMERA_MODE = 'CAMERA_MODE'; +const CAMERA_SD_CARD = 'CAMERA_SD_CARD'; +const ROOM_SIZE = 'ROOM_SIZE'; +const CAMERA_FORMAT_PICTURES = 'CAMERA_FORMAT_PICTURES'; +const SPEAKER_TYPE_FRONT = 'SPEAKER_TYPE_FRONT'; + +// List of attribute values +const WHITE = 'COLOUR_HT_WHITE'; +const TITAN = 'COLOUR_HT_TITAN'; +const SDHC = 'SDHC'; +const JPEG = 'JPEG'; +const P5 = 'P5'; + +context('Product Configuration', () => { + beforeEach(() => { + cy.visit('/'); + }); + + describe('Navigate to Product Configuration Page', () => { + it('should be able to navigate from the product search result', () => { + cy.server(); + cy.route( + 'GET', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/products/suggestions?term=CONF_CAMERA_SL*` + ).as('productSearch'); + productSearch.searchForProduct(testProduct); + cy.wait('@productSearch'); + configuration.clickOnConfigureBtnInCatalog(); + }); + + it('should be able to navigate from the product details page', () => { + configuration.goToPDPage(electronicsShop, testProduct); + configuration.clickOnConfigureBtnInCatalog(); + }); + + it('should be able to navigate from the overview page', () => { + configurationOverview.goToConfigOverviewPage( + electronicsShop, + testProduct + ); + configurationOverview.navigateToConfigurationPage(); + configuration.checkConfigPageDisplayed(); + }); + }); + + describe('Configure Product', () => { + it('should support image attribute type - single selection', () => { + configuration.goToConfigurationPage( + electronicsShop, + testProductMultiLevel + ); + configuration.checkAttributeDisplayed(ROOM_SIZE, radioGroup); + configuration.selectAttribute(COLOUR_HT, single_selection_image, WHITE); + configuration.checkImageSelected( + single_selection_image, + COLOUR_HT, + WHITE + ); + configuration.selectAttribute(COLOUR_HT, single_selection_image, TITAN); + configuration.checkImageSelected( + single_selection_image, + COLOUR_HT, + TITAN + ); + }); + + it('should keep checkboxes selected after group change', () => { + cy.server(); + cy.route( + 'PATCH', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/ccpconfigurator/*` + ).as('updateConfig'); + configuration.goToConfigurationPage(electronicsShop, testProduct); + configuration.checkAttributeDisplayed(CAMERA_MODE, radioGroup); + configuration.clickOnNextBtn(SPECIFICATION); + configuration.selectAttribute(CAMERA_SD_CARD, checkBoxList, SDHC); + cy.wait('@updateConfig'); + configuration.clickOnPreviousBtn(BASICS); + configuration.clickOnNextBtn(SPECIFICATION); + configuration.checkValueSelected(checkBoxList, CAMERA_SD_CARD, SDHC); + }); + }); + + describe('Group Status', () => { + it('should set group status for single level product', () => { + cy.server(); + cy.route( + 'PATCH', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/ccpconfigurator/*` + ).as('updateConfig'); + configuration.goToConfigurationPage(electronicsShop, testProduct); + configuration.checkGroupMenuDisplayed(); + + //is that no status is displayed initially + configuration.checkStatusIconNotDisplayed(BASICS); + configuration.checkStatusIconNotDisplayed(SPECIFICATION); + configuration.checkStatusIconNotDisplayed(DISPLAY); + configuration.checkStatusIconNotDisplayed(LENS); + configuration.checkStatusIconNotDisplayed(OPTIONS); + + // navigate to Specification, is that Basics status changes to Error + configuration.clickOnNextBtn(SPECIFICATION); + configuration.checkStatusIconDisplayed(BASICS, ERROR); + configuration.checkStatusIconNotDisplayed(SPECIFICATION); + configuration.checkStatusIconNotDisplayed(DISPLAY); + configuration.checkStatusIconNotDisplayed(LENS); + configuration.checkStatusIconNotDisplayed(OPTIONS); + + // navigate to Display, is that Specification status changes to Error + configuration.clickOnNextBtn(DISPLAY); + configuration.checkStatusIconDisplayed(BASICS, ERROR); + configuration.checkStatusIconDisplayed(SPECIFICATION, ERROR); + configuration.checkStatusIconNotDisplayed(DISPLAY); + configuration.checkStatusIconNotDisplayed(LENS); + configuration.checkStatusIconNotDisplayed(OPTIONS); + + // complete group Display, navigate back, is status changes to Complete + configuration.selectAttribute(CAMERA_DISPLAY, radioGroup, P5); + cy.wait('@updateConfig'); + configuration.clickOnPreviousBtn(SPECIFICATION); + configuration.checkStatusIconDisplayed(BASICS, ERROR); + configuration.checkStatusIconDisplayed(SPECIFICATION, ERROR); + configuration.checkStatusIconDisplayed(DISPLAY, COMPLETE); + configuration.checkStatusIconNotDisplayed(LENS); + configuration.checkStatusIconNotDisplayed(OPTIONS); + + // select mandatory field in group Specification + // and check whether status changes to complete + configuration.selectAttribute(CAMERA_FORMAT_PICTURES, radioGroup, JPEG); + cy.wait('@updateConfig'); + configuration.checkStatusIconDisplayed(BASICS, ERROR); + configuration.checkStatusIconDisplayed(SPECIFICATION, COMPLETE); + configuration.checkStatusIconDisplayed(DISPLAY, COMPLETE); + configuration.checkStatusIconNotDisplayed(LENS); + configuration.checkStatusIconNotDisplayed(OPTIONS); + }); + + it('should set group status for multi level product', () => { + configuration.goToConfigurationPage( + electronicsShop, + testProductMultiLevel + ); + configuration.checkGroupMenuDisplayed(); + + // no status should be displayed initially + configuration.checkStatusIconNotDisplayed(GENERAL); + configuration.checkStatusIconNotDisplayed(VIDEO_SYSTEM); + configuration.checkStatusIconNotDisplayed(AUDIO_SYSTEM); + configuration.checkStatusIconNotDisplayed(SOURCE_COMPONENTS); + + // navigate to video system subgroup, no status initially + configuration.clickOnNextBtn(PROJECTOR); + configuration.checkStatusIconNotDisplayed(PROJECTOR); + configuration.checkStatusIconNotDisplayed(FLAT_PANEL); + + // navigate to flat-panel TV, group projector should be completed + configuration.clickOnNextBtn(FLAT_PANEL); + configuration.checkStatusIconDisplayed(PROJECTOR, COMPLETE); + configuration.checkStatusIconNotDisplayed(FLAT_PANEL); + + // navigate back to group projector, status should be completed + configuration.clickOnPreviousBtn(PROJECTOR); + configuration.checkStatusIconDisplayed(PROJECTOR, COMPLETE); + configuration.checkStatusIconDisplayed(FLAT_PANEL, COMPLETE); + + // navigate back to General, check completed status + configuration.clickOnPreviousBtn(GENERAL); + configuration.checkStatusIconDisplayed(GENERAL, COMPLETE); + configuration.checkStatusIconDisplayed(VIDEO_SYSTEM, COMPLETE); + + // navigate to Audio System subgroup, is no status is displayed initially + configuration.clickOnNextBtn(PROJECTOR); + configuration.clickOnNextBtn(FLAT_PANEL); + configuration.clickOnNextBtn(FRONT_SPEAKERS); + configuration.checkStatusIconNotDisplayed(FRONT_SPEAKERS); + configuration.checkStatusIconNotDisplayed(CENTER_SPEAKER); + configuration.checkStatusIconNotDisplayed(REAR_SPEAKER); + configuration.checkStatusIconNotDisplayed(SUBWOOFER); + + // navigate to Center Speaker + configuration.clickOnNextBtn(CENTER_SPEAKER); + configuration.checkStatusIconDisplayed(FRONT_SPEAKERS, COMPLETE); + + // navigate back to Front Speaker, check completed status + configuration.clickOnPreviousBtn(FRONT_SPEAKERS); + configuration.checkStatusIconDisplayed(FRONT_SPEAKERS, COMPLETE); + configuration.checkStatusIconDisplayed(CENTER_SPEAKER, COMPLETE); + configuration.checkStatusIconNotDisplayed(REAR_SPEAKER); + configuration.checkStatusIconNotDisplayed(SUBWOOFER); + + // navigate back to General group, is that Audio system is not fully completed + configuration.clickOnPreviousBtn(FLAT_PANEL); + configuration.clickOnPreviousBtn(PROJECTOR); + configuration.clickOnPreviousBtn(GENERAL); + + configuration.checkStatusIconDisplayed(GENERAL, COMPLETE); + configuration.checkStatusIconDisplayed(VIDEO_SYSTEM, COMPLETE); + configuration.checkStatusIconNotDisplayed(AUDIO_SYSTEM); + configuration.checkStatusIconNotDisplayed(SOURCE_COMPONENTS); + }); + }); + + describe('Group Handling', () => { + it('should navigate between groups', () => { + configuration.goToConfigurationPage(electronicsShop, testProduct); + configuration.clickOnNextBtn(SPECIFICATION); + configuration.clickOnNextBtn(DISPLAY); + configuration.clickOnPreviousBtn(SPECIFICATION); + }); + + it('should check if group buttons are clickable', () => { + configuration.goToConfigurationPage(electronicsShop, testProduct); + configuration.checkNextBtnEnabled(); + configuration.checkPreviousBtnDisabled(); + + configuration.clickOnNextBtn(SPECIFICATION); + configuration.checkPreviousBtnEnabled(); + configuration.clickOnNextBtn(DISPLAY); + configuration.clickOnNextBtn(LENS); + configuration.clickOnNextBtn(OPTIONS); + configuration.checkNextBtnDisabled(); + }); + + it('should navigate using the group menu', () => { + configuration.goToConfigurationPage(electronicsShop, testProduct); + configuration.checkAttributeDisplayed(CAMERA_MODE, radioGroup); + + configuration.clickOnGroup(2); + configuration.checkAttributeDisplayed(CAMERA_DISPLAY, radioGroup); + configuration.clickOnGroup(1); + configuration.checkAttributeDisplayed(CAMERA_PIXELS, radioGroup); + }); + + it('should navigate using the previous and next button for multi level product', () => { + configuration.goToConfigurationPage( + electronicsShop, + testProductMultiLevel + ); + configuration.clickOnNextBtn(PROJECTOR); + configuration.clickOnNextBtn(FLAT_PANEL); + configuration.clickOnPreviousBtn(PROJECTOR); + }); + + it('should navigate using the group menu for multi level product', () => { + configuration.goToConfigurationPage( + electronicsShop, + testProductMultiLevel + ); + configuration.clickOnGroup(2); + configuration.checkAttributeDisplayed(SPEAKER_TYPE_FRONT, radioGroup); + }); + }); +}); diff --git a/projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-configuration-textfield.e2e-spec.ts b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-configuration-textfield.e2e-spec.ts new file mode 100644 index 00000000000..4a53578a2d8 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/integration/regression/product-configuration-textfield.e2e-spec.ts @@ -0,0 +1,76 @@ +import * as cart from '../../helpers/cart'; +import * as productSearch from '../../helpers/product-search'; +import * as textfiledConfiguration from '../../helpers/textfield-configuration'; + +const electronicsShop = 'electronics-spa'; +const testProduct = '1934793'; +const ENGRAVED_TEXT = 'Engraved Text'; +const HALLO = 'Hallo'; + +context('Textfield Configuration', () => { + before(() => { + cy.visit('/'); + }); + + describe('Navigate to Textfield Configuration Page', () => { + it('should be able to navigate from the product search result', () => { + productSearch.searchForProduct(testProduct); + textfiledConfiguration.clickOnConfigureButton(); + }); + + it('should be able to navigate from the product details page', () => { + textfiledConfiguration.goToProductDetailsPage( + electronicsShop, + testProduct + ); + textfiledConfiguration.clickOnConfigureButton(); + }); + + it('should be able to navigate from the cart', () => { + textfiledConfiguration.goToConfigurationPage( + electronicsShop, + testProduct + ); + textfiledConfiguration.checkConfigurationPageIsDisplayed(); + textfiledConfiguration.addToCartAndVerify(electronicsShop, testProduct); + textfiledConfiguration.clickOnEditConfigurationLink(0); + }); + + it('should be able to navigate from the cart after adding product directly to the cart', () => { + textfiledConfiguration.goToProductDetailsPage( + electronicsShop, + testProduct + ); + textfiledConfiguration.clickOnAddToCartBtnOnPD(); + textfiledConfiguration.clickOnViewCartBtnOnPD(); + cart.verifyCartNotEmpty(); + textfiledConfiguration.clickOnEditConfigurationLink(0); + }); + }); + + describe('Configure Product and add to cart', () => { + it('should enter value and add textfield product to cart', () => { + textfiledConfiguration.goToConfigurationPage( + electronicsShop, + testProduct + ); + textfiledConfiguration.checkConfigurationPageIsDisplayed(); + textfiledConfiguration.checkAttributeDisplayed(ENGRAVED_TEXT); + textfiledConfiguration.selectAttribute(ENGRAVED_TEXT, HALLO); + textfiledConfiguration.addToCartAndVerify(electronicsShop, testProduct); + }); + + it('should be able to update a configured product from the cart', () => { + textfiledConfiguration.goToConfigurationPage( + electronicsShop, + testProduct + ); + textfiledConfiguration.checkConfigurationPageIsDisplayed(); + textfiledConfiguration.addToCartAndVerify(electronicsShop, testProduct); + textfiledConfiguration.clickOnEditConfigurationLink(0); + textfiledConfiguration.checkAttributeDisplayed(ENGRAVED_TEXT); + textfiledConfiguration.selectAttribute(ENGRAVED_TEXT, HALLO); + textfiledConfiguration.addToCartAndVerify(electronicsShop, testProduct); + }); + }); +}); diff --git a/projects/storefrontapp-e2e-cypress/package.json b/projects/storefrontapp-e2e-cypress/package.json index 7c813288717..14636e248ac 100644 --- a/projects/storefrontapp-e2e-cypress/package.json +++ b/projects/storefrontapp-e2e-cypress/package.json @@ -16,6 +16,7 @@ "cy:run:mobile:ci": "cypress run --config-file cypress.ci.1905.json --spec \"cypress/integration/mobile/**/*\"", "cy:cds:run:ci:2005": "cypress run --config-file cypress.ci.2005.json --record --key $CYPRESS_KEY --tag \"2005,b2c,all-cds\" --spec \"cypress/integration/!(vendor|b2b)/**/*.e2e-spec.ts,cypress/integration/vendor/cds/**/*.e2e-spec.ts\"", "cy:run:regression:ci": "cypress run --config-file cypress.ci.1905.json --spec \"cypress/integration/regression/**/*\"", + "cy:run:product-configuration": "cypress run --spec \"cypress/integration/**/*-configuration*.*e2e-spec.ts\"", "cy:run:b2b": "cypress run --config-file cypress.ci.b2b.json --spec \"cypress/integration/b2b/**/*\"", "cy:run:b2b:ci": "cypress run --config-file cypress.ci.b2b.json --record --key $CYPRESS_KEY --tag \"2005,b2b,all,parallel\" --spec \"cypress/integration/b2b/**/*\"", "cy:open:b2b": "cypress open --config-file cypress.ci.b2b.json --config testFiles=**/b2b/**/*" diff --git a/projects/storefrontapp/src/app/app.module.ts b/projects/storefrontapp/src/app/app.module.ts index 772f78077d4..24ac7c3a781 100644 --- a/projects/storefrontapp/src/app/app.module.ts +++ b/projects/storefrontapp/src/app/app.module.ts @@ -1,4 +1,5 @@ import { registerLocaleData } from '@angular/common'; +import { HttpClientModule } from '@angular/common/http'; import localeDe from '@angular/common/locales/de'; import localeJa from '@angular/common/locales/ja'; import localeZh from '@angular/common/locales/zh'; @@ -7,15 +8,17 @@ import { BrowserModule, BrowserTransferStateModule, } from '@angular/platform-browser'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { translationChunksConfig, translations } from '@spartacus/assets'; import { ConfigModule, TestConfigModule } from '@spartacus/core'; +import { configuratorTranslations } from '@spartacus/product-configurator/common/assets'; +import { RulebasedConfiguratorRootModule } from '@spartacus/product-configurator/rulebased/root'; +import { TextfieldConfiguratorRootModule } from '@spartacus/product-configurator/textfield/root'; import { StorefrontComponent } from '@spartacus/storefront'; import { environment } from '../environments/environment'; import { TestOutletModule } from '../test-outlets/test-outlet.module'; -import { HttpClientModule } from '@angular/common/http'; -import { StoreModule } from '@ngrx/store'; -import { EffectsModule } from '@ngrx/effects'; import { AppRoutingModule } from './app-routing.module'; import { SpartacusModule } from './spartacus/spartacus.module'; @@ -65,6 +68,32 @@ if (!environment.production) { level: '2.1', }, }), + + // PRODUCT CONFIGURATOR + // TODO(#10883): Move product configurator to a separate feature module + ConfigModule.withConfig({ + i18n: { + resources: configuratorTranslations, + }, + featureModules: { + rulebased: { + module: () => + import('@spartacus/product-configurator/rulebased').then( + (m) => m.RulebasedConfiguratorModule + ), + }, + textfield: { + module: () => + import('@spartacus/product-configurator/textfield').then( + (m) => m.TextfieldConfiguratorModule + ), + }, + }, + }), + RulebasedConfiguratorRootModule, + TextfieldConfiguratorRootModule, + // PRODUCT CONFIGURATOR END + TestOutletModule, // custom usages of cxOutletRef only for e2e testing TestConfigModule.forRoot({ cookie: 'cxConfigE2E' }), // Injects config dynamically from e2e tests. Should be imported after other config modules. diff --git a/projects/storefrontapp/src/environments/productconfig/productconfig.feature.ts b/projects/storefrontapp/src/environments/productconfig/productconfig.feature.ts new file mode 100644 index 00000000000..7941084b7ba --- /dev/null +++ b/projects/storefrontapp/src/environments/productconfig/productconfig.feature.ts @@ -0,0 +1,31 @@ +import { ConfigModule } from '@spartacus/core'; +import { configuratorTranslations } from '@spartacus/product-configurator/common/assets'; +import { RulebasedConfiguratorRootModule } from '@spartacus/product-configurator/rulebased/root'; +import { TextfieldConfiguratorRootModule } from '@spartacus/product-configurator/textfield/root'; +import { FeatureEnvironment } from '../models/feature.model'; + +export const productConfigFeature: FeatureEnvironment = { + imports: [ + ConfigModule.withConfig({ + i18n: { + resources: configuratorTranslations, + }, + featureModules: { + rulebased: { + module: () => + import('@spartacus/product-configurator/rulebased').then( + (m) => m.RulebasedConfiguratorModule + ), + }, + textfield: { + module: () => + import('@spartacus/product-configurator/textfield').then( + (m) => m.TextfieldConfiguratorModule + ), + }, + }, + }), + RulebasedConfiguratorRootModule, + TextfieldConfiguratorRootModule, + ], +}; diff --git a/projects/storefrontapp/src/styles/lib-product-configurator.scss b/projects/storefrontapp/src/styles/lib-product-configurator.scss new file mode 100644 index 00000000000..b08c5e97db2 --- /dev/null +++ b/projects/storefrontapp/src/styles/lib-product-configurator.scss @@ -0,0 +1 @@ +@import 'product-configurator'; diff --git a/projects/storefrontapp/tsconfig.app.prod.json b/projects/storefrontapp/tsconfig.app.prod.json index 1189c2a56ea..a9561f44313 100644 --- a/projects/storefrontapp/tsconfig.app.prod.json +++ b/projects/storefrontapp/tsconfig.app.prod.json @@ -32,20 +32,28 @@ "@spartacus/organization/order-approval/root": [ "dist/organization/order-approval/root" ], - "@spartacus/product/configurators/common": [ - "dist/product/configurators/common" + "@spartacus/product": ["dist/product"], + "@spartacus/product-configurator": ["dist/product-configurator"], + "@spartacus/product-configurator/common": [ + "dist/product-configurator/common" ], - "@spartacus/product/configurators/cpq": [ - "dist/product/configurators/cpq" + + "@spartacus/product-configurator/common/assets": [ + "dist/product-configurator/common/assets" ], - "@spartacus/product/configurators": ["dist/product/configurators"], - "@spartacus/product/configurators/textfield": [ - "dist/product/configurators/textfield" + "@spartacus/product-configurator/textfield": [ + "dist/product-configurator/textfield" ], - "@spartacus/product/configurators/variant": [ - "dist/product/configurators/variant" + "@spartacus/product-configurator/textfield/root": [ + "dist/product-configurator/textfield/root" ], - "@spartacus/product": ["dist/product"], + "@spartacus/product-configurator/rulebased": [ + "dist/product-configurator/rulebased" + ], + "@spartacus/product-configurator/rulebased/root": [ + "dist/product-configurator/rulebased/root" + ], + "@spartacus/storefinder/assets": ["dist/storefinder/assets"], "@spartacus/storefinder/components": ["dist/storefinder/components"], "@spartacus/storefinder/core": ["dist/storefinder/core"], diff --git a/projects/storefrontapp/tsconfig.server.json b/projects/storefrontapp/tsconfig.server.json index 88658b0767d..e94dd2c5b27 100644 --- a/projects/storefrontapp/tsconfig.server.json +++ b/projects/storefrontapp/tsconfig.server.json @@ -36,22 +36,30 @@ "@spartacus/organization/order-approval/root": [ "../../feature-libs/organization/order-approval/root/public_api" ], - "@spartacus/product/configurators/common": [ - "../../feature-libs/product/configurators/common/public_api" + "@spartacus/product": ["../../feature-libs/product/public_api"], + "@spartacus/product-configurator": [ + "../../feature-libs/product-configurator/public_api" ], - "@spartacus/product/configurators/cpq": [ - "../../feature-libs/product/configurators/cpq/public_api" + "@spartacus/product-configurator/common": [ + "../../feature-libs/product-configurator/common/public_api" ], - "@spartacus/product/configurators": [ - "../../feature-libs/product/configurators/public_api" + + "@spartacus/product-configurator/common/assets": [ + "../../feature-libs/product-configurator/common/assets/public_api" ], - "@spartacus/product/configurators/textfield": [ - "../../feature-libs/product/configurators/textfield/public_api" + "@spartacus/product-configurator/textfield": [ + "../../feature-libs/product-configurator/textfield/public_api" ], - "@spartacus/product/configurators/variant": [ - "../../feature-libs/product/configurators/variant/public_api" + + "@spartacus/product-configurator/textfield/root": [ + "../../feature-libs/product-configurator/textfield/root/public_api" + ], + "@spartacus/product-configurator/rulebased": [ + "../../feature-libs/product-configurator/rulebased/public_api" + ], + "@spartacus/product-configurator/rulebased/root": [ + "../../feature-libs/product-configurator/rulebased/root/public_api" ], - "@spartacus/product": ["../../feature-libs/product/public_api"], "@spartacus/storefinder/assets": [ "../../feature-libs/storefinder/assets/public_api" ], diff --git a/projects/storefrontapp/tsconfig.server.prod.json b/projects/storefrontapp/tsconfig.server.prod.json index 316ce901fd3..2744e83ffce 100644 --- a/projects/storefrontapp/tsconfig.server.prod.json +++ b/projects/storefrontapp/tsconfig.server.prod.json @@ -33,20 +33,29 @@ "@spartacus/organization/order-approval/root": [ "../../dist/organization/order-approval/root" ], - "@spartacus/product/configurators/common": [ - "../../dist/product/configurators/common" + "@spartacus/product-configurator/common/assets": [ + "../../dist/product-configurator/common/assets" ], - "@spartacus/product/configurators/cpq": [ - "../../dist/product/configurators/cpq" + "@spartacus/product-configurator/common": [ + "../../dist/product-configurator/common" ], - "@spartacus/product/configurators": ["../../dist/product/configurators"], - "@spartacus/product/configurators/textfield": [ - "../../dist/product/configurators/textfield" + "@spartacus/product-configurator": ["../../dist/product-configurator"], + "@spartacus/product-configurator/rulebased": [ + "../../dist/product-configurator/rulebased" ], - "@spartacus/product/configurators/variant": [ - "../../dist/product/configurators/variant" + "@spartacus/product-configurator/rulebased/root": [ + "../../dist/product-configurator/rulebased/root" + ], + "@spartacus/product-configurator/textfield": [ + "../../dist/product-configurator/textfield" + ], + "@spartacus/product-configurator/textfield/root": [ + "../../dist/product-configurator/textfield/root" ], "@spartacus/product": ["../../dist/product"], + "@spartacus/qualtrics/components": ["../../dist/qualtrics/components"], + "@spartacus/qualtrics": ["../../dist/qualtrics"], + "@spartacus/qualtrics/root": ["../../dist/qualtrics/root"], "@spartacus/storefinder/assets": ["../../dist/storefinder/assets"], "@spartacus/storefinder/components": [ "../../dist/storefinder/components" @@ -55,9 +64,6 @@ "@spartacus/storefinder": ["../../dist/storefinder"], "@spartacus/storefinder/occ": ["../../dist/storefinder/occ"], "@spartacus/storefinder/root": ["../../dist/storefinder/root"], - "@spartacus/qualtrics/components": ["../../dist/qualtrics/components"], - "@spartacus/qualtrics": ["../../dist/qualtrics"], - "@spartacus/qualtrics/root": ["../../dist/qualtrics/root"], "@spartacus/cdc": ["../../dist/cdc"], "@spartacus/cds": ["../../dist/cds"], "@spartacus/assets": ["../../dist/assets"], diff --git a/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item-component.model.ts b/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item-component.model.ts new file mode 100644 index 00000000000..22c24af0cc5 --- /dev/null +++ b/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item-component.model.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { OrderEntry, PromotionLocation } from '@spartacus/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { CartItemComponentOptions } from './cart-item.component'; + +export enum CartItemComponentOutlets { + START = 'cx-cart-item.start', + INFORMATION = 'cx-cart-item.information', +} + +export interface CartItemContextModel { + compact?: boolean; + readonly?: boolean; + item?: OrderEntry; + quantityControl?: FormControl; + promotionLocation?: PromotionLocation; + options?: CartItemComponentOptions; +} + +@Injectable() +export class CartItemContext { + context$: Observable = new BehaviorSubject< + CartItemContextModel + >({}); +} diff --git a/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item.component.html b/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item.component.html index f2d4d877215..41be3bced97 100644 --- a/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item.component.html +++ b/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item.component.html @@ -1,3 +1,5 @@ + +
@@ -28,6 +30,9 @@
{{ 'cartItems.id' | cxTranslate }} {{ item.product.code }}
+ + +
{{ item.product.stock.stockLevel }}
-
+ +
{{ 'saveForLaterItems.forceInStock' | cxTranslate }} -
+
+
diff --git a/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item.component.spec.ts b/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item.component.spec.ts index 1c087d8716a..eba75332aef 100644 --- a/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item.component.spec.ts +++ b/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item.component.spec.ts @@ -5,8 +5,9 @@ import { Input, Pipe, PipeTransform, + SimpleChange, } from '@angular/core'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ControlContainer, FormControl, @@ -18,6 +19,7 @@ import { FeaturesConfigModule, I18nTestingModule } from '@spartacus/core'; import { ModalDirective } from 'projects/storefrontlib/src/shared/components/modal/modal.directive'; import { PromotionService } from '../../../../shared/services/promotion/promotion.service'; import { MockFeatureLevelDirective } from '../../../../shared/test/mock-feature-level-directive'; +import { CartItemContext } from './cart-item-component.model'; import { CartItemComponent } from './cart-item.component'; @Pipe({ @@ -151,6 +153,41 @@ describe('CartItemComponent', () => { expect(cartItemComponent).toBeTruthy(); }); + it('should know initial empty item context', () => { + const cartItemContext: CartItemContext = cartItemComponent[ + 'cartItemContext' + ] as CartItemContext; + expect(cartItemContext).toBeDefined(); + + cartItemContext.context$ + .subscribe((cartContextModel) => { + expect(cartContextModel).toEqual({}); + }) + .unsubscribe(); + }); + it('should know item context content after onChanges fired', () => { + const cartItemContext: CartItemContext = cartItemComponent[ + 'cartItemContext' + ] as CartItemContext; + expect(cartItemContext).toBeDefined(); + cartItemComponent.ngOnChanges({ + item: new SimpleChange( + undefined, + { + product: mockProduct, + updateable: true, + statusSummaryList: [], + }, + false + ), + }); + cartItemContext.context$ + .subscribe((cartContextModel) => { + expect(cartContextModel.item.product).toEqual(mockProduct); + }) + .unsubscribe(); + }); + it('should create cart details component', () => { featureConfig.isEnabled.and.returnValue(true); expect(cartItemComponent).toBeTruthy(); diff --git a/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item.component.ts b/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item.component.ts index 41433e980c5..78ff39f72ae 100644 --- a/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item.component.ts +++ b/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/cart-item.component.ts @@ -1,12 +1,25 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { + Component, + Input, + OnChanges, + OnInit, + Optional, + SimpleChanges, +} from '@angular/core'; import { FormControl } from '@angular/forms'; import { OrderEntry, PromotionLocation, PromotionResult, } from '@spartacus/core'; -import { Observable } from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { PromotionService } from '../../../../shared/services/promotion/promotion.service'; +import { ICON_TYPE } from '../../../misc/icon/icon.model'; +import { + CartItemComponentOutlets, + CartItemContext, + CartItemContextModel, +} from './cart-item-component.model'; /** * @deprecated since 3.0 - use `OrderEntry` instead @@ -18,6 +31,8 @@ export interface Item { basePrice?: any; totalPrice?: any; updateable?: boolean; + statusSummaryList?: any[]; + configurationInfos?: any[]; } export interface CartItemComponentOptions { @@ -28,8 +43,9 @@ export interface CartItemComponentOptions { @Component({ selector: 'cx-cart-item', templateUrl: './cart-item.component.html', + providers: [CartItemContext], }) -export class CartItemComponent implements OnInit { +export class CartItemComponent implements OnInit, OnChanges { @Input() compact = false; @Input() item: OrderEntry; @Input() readonly = false; @@ -44,8 +60,13 @@ export class CartItemComponent implements OnInit { }; appliedProductPromotions$: Observable; + iconTypes = ICON_TYPE; + readonly Outlets = CartItemComponentOutlets; - constructor(protected promotionService: PromotionService) {} + constructor( + protected promotionService: PromotionService, + @Optional() protected cartItemContext?: CartItemContext + ) {} ngOnInit() { this.appliedProductPromotions$ = this.promotionService.getProductPromotionForEntry( @@ -54,6 +75,24 @@ export class CartItemComponent implements OnInit { ); } + ngOnChanges(changes: SimpleChanges) { + this.populateCartItemContext(changes); + } + + private populateCartItemContext(changes: SimpleChanges) { + if (this.cartItemContext) { + const newChunk = Object.entries(changes).reduce( + (acc, [key, change]) => ({ ...acc, [key]: change.currentValue }), + {} as CartItemContextModel + ); + + const context$ = this.cartItemContext.context$ as BehaviorSubject< + CartItemContextModel + >; + context$.next({ ...context$.value, ...newChunk }); + } + } + isProductOutOfStock(product: any) { // TODO Move stocklevelstatuses across the app to an enum return ( diff --git a/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/index.ts b/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/index.ts new file mode 100644 index 00000000000..4574d954aec --- /dev/null +++ b/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-item/index.ts @@ -0,0 +1,2 @@ +export * from './cart-item-component.model'; +export * from './cart-item.component'; diff --git a/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-shared.module.ts b/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-shared.module.ts index 0eb58ffe4da..365ee7aec71 100644 --- a/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-shared.module.ts +++ b/projects/storefrontlib/src/cms-components/cart/cart-shared/cart-shared.module.ts @@ -4,6 +4,8 @@ import { ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { FeaturesConfigModule, I18nModule, UrlModule } from '@spartacus/core'; +import { IconModule } from '../../../cms-components/misc/icon/icon.module'; +import { OutletModule } from '../../../cms-structure/outlet/outlet.module'; import { ItemCounterModule } from '../../../shared/components/item-counter/item-counter.module'; import { MediaModule } from '../../../shared/components/media/media.module'; import { ModalModule } from '../../../shared/components/modal/modal.module'; @@ -23,10 +25,12 @@ import { OrderSummaryComponent } from './order-summary/order-summary.component'; NgbModule, PromotionsModule, I18nModule, + IconModule, MediaModule, ItemCounterModule, FeaturesConfigModule, ModalModule, + OutletModule, ], declarations: [ CartItemComponent, diff --git a/projects/storefrontlib/src/cms-components/cart/cart-shared/index.ts b/projects/storefrontlib/src/cms-components/cart/cart-shared/index.ts index c59e8396dbf..d38448af22c 100644 --- a/projects/storefrontlib/src/cms-components/cart/cart-shared/index.ts +++ b/projects/storefrontlib/src/cms-components/cart/cart-shared/index.ts @@ -1,4 +1,4 @@ -export * from './cart-item/cart-item.component'; export * from './cart-item-list/cart-item-list.component'; -export * from './order-summary/order-summary.component'; +export * from './cart-item/index'; export * from './cart-shared.module'; +export * from './order-summary/order-summary.component'; diff --git a/projects/storefrontlib/src/cms-components/misc/icon/fontawesome-icon.config.ts b/projects/storefrontlib/src/cms-components/misc/icon/fontawesome-icon.config.ts index 9667888a81c..d14ab49bd75 100644 --- a/projects/storefrontlib/src/cms-components/misc/icon/fontawesome-icon.config.ts +++ b/projects/storefrontlib/src/cms-components/misc/icon/fontawesome-icon.config.ts @@ -9,6 +9,7 @@ export const fontawesomeIconConfig: IconConfig = { STAR: 'fas fa-star', GRID: 'fas fa-th-large', LIST: 'fas fa-bars', + CARET_UP: 'fas fa-angle-up', CARET_DOWN: 'fas fa-angle-down', CARET_RIGHT: 'fas fa-angle-right', CARET_LEFT: 'fas fa-angle-left', diff --git a/projects/storefrontlib/src/cms-components/misc/icon/icon.model.ts b/projects/storefrontlib/src/cms-components/misc/icon/icon.model.ts index e6cb8b6c6af..a7f17c07eae 100644 --- a/projects/storefrontlib/src/cms-components/misc/icon/icon.model.ts +++ b/projects/storefrontlib/src/cms-components/misc/icon/icon.model.ts @@ -10,6 +10,7 @@ export enum ICON_TYPE { GRID = 'GRID', LIST = 'LIST', CARET_DOWN = 'CARET_DOWN', + CARET_UP = 'CARET_UP', CARET_LEFT = 'CARET_LEFT', CARET_RIGHT = 'CARET_RIGHT', CLOSE = 'CLOSE', diff --git a/projects/storefrontlib/src/cms-components/product/current-product.service.ts b/projects/storefrontlib/src/cms-components/product/current-product.service.ts index 6d00f173615..1f3d21e1520 100644 --- a/projects/storefrontlib/src/cms-components/product/current-product.service.ts +++ b/projects/storefrontlib/src/cms-components/product/current-product.service.ts @@ -29,8 +29,7 @@ export class CurrentProductService { getProduct( scopes?: (ProductScope | string)[] | ProductScope | string ): Observable { - return this.routingService.getRouterState().pipe( - map((state) => state.state.params['productCode']), + return this.getCode().pipe( distinctUntilChanged(), switchMap((productCode: string) => { return productCode @@ -43,4 +42,10 @@ export class CurrentProductService { filter((product) => product !== undefined) ); } + + protected getCode(): Observable { + return this.routingService + .getRouterState() + .pipe(map((state) => state.state.params['productCode'])); + } } diff --git a/projects/storefrontlib/src/cms-components/product/index.ts b/projects/storefrontlib/src/cms-components/product/index.ts index ba0cfdfb40b..90807083805 100644 --- a/projects/storefrontlib/src/cms-components/product/index.ts +++ b/projects/storefrontlib/src/cms-components/product/index.ts @@ -5,13 +5,14 @@ export * from './product-images/product-images.component'; export * from './product-images/product-images.module'; export * from './product-intro/product-intro.component'; export * from './product-intro/product-intro.module'; +export * from './product-list-item-context'; export * from './product-list/index'; export * from './product-outlets.model'; export * from './product-summary/product-summary.component'; export * from './product-summary/product-summary.module'; export * from './product-tabs/index'; -export * from './product-variants/index'; export * from './product-variants/guards/index'; +export * from './product-variants/index'; export * from './stock-notification/stock-notification-dialog/stock-notification-dialog.component'; export * from './stock-notification/stock-notification.component'; export * from './stock-notification/stock-notification.module'; diff --git a/projects/storefrontlib/src/cms-components/product/product-list-item-context.spec.ts b/projects/storefrontlib/src/cms-components/product/product-list-item-context.spec.ts new file mode 100644 index 00000000000..86a0d03f9d6 --- /dev/null +++ b/projects/storefrontlib/src/cms-components/product/product-list-item-context.spec.ts @@ -0,0 +1,51 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { I18nTestingModule } from '@spartacus/core'; +import { + ProductListItemContext, + ProductListItemContextOwner, +} from './product-list-item-context'; + +describe('ProductListItemContext', () => { + let productListItemContext: ProductListItemContext; + + const mockProduct = { + name: 'Test product', + nameHtml: 'Test product', + summary: 'Test summary', + code: '1', + }; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule, I18nTestingModule], + + providers: [ + { + provide: ProductListItemContext, + useClass: ProductListItemContextOwner, + }, + ], + }); + }) + ); + + beforeEach(() => { + productListItemContext = TestBed.inject(ProductListItemContext); + }); + + it('should create', () => { + expect(productListItemContext).toBeTruthy(); + }); + + it('should transmit product', (done) => { + (productListItemContext as ProductListItemContextOwner).setProduct( + mockProduct + ); + productListItemContext.product$.subscribe((product) => { + expect(product).toBe(mockProduct); + done(); + }); + }); +}); diff --git a/projects/storefrontlib/src/cms-components/product/product-list-item-context.ts b/projects/storefrontlib/src/cms-components/product/product-list-item-context.ts new file mode 100644 index 00000000000..88fda751731 --- /dev/null +++ b/projects/storefrontlib/src/cms-components/product/product-list-item-context.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { Product } from '@spartacus/core'; +import { Observable, ReplaySubject } from 'rxjs'; + +@Injectable() +export abstract class ProductListItemContext { + readonly product$: Observable; +} + +@Injectable() +export class ProductListItemContextOwner extends ProductListItemContext { + readonly product$ = new ReplaySubject(1); + + setProduct(product: Product) { + this.product$.next(product); + } +} diff --git a/projects/storefrontlib/src/cms-components/product/product-list/container/product-scroll/product-scroll.component.spec.ts b/projects/storefrontlib/src/cms-components/product/product-list/container/product-scroll/product-scroll.component.spec.ts index 6ec3c4000c4..10337839baa 100644 --- a/projects/storefrontlib/src/cms-components/product/product-list/container/product-scroll/product-scroll.component.spec.ts +++ b/projects/storefrontlib/src/cms-components/product/product-list/container/product-scroll/product-scroll.component.spec.ts @@ -14,12 +14,11 @@ import { ProductGridItemComponent } from '../..'; import { MediaComponent } from '../../../../../shared/components/media'; import { SpinnerModule } from '../../../../../shared/components/spinner/spinner.module'; import { ViewConfig } from '../../../../../shared/config/view-config'; +import { MockFeatureLevelDirective } from '../../../../../shared/test/mock-feature-level-directive'; import { ViewModes } from '../../product-view/product-view.component'; import { ProductListComponentService } from '../product-list-component.service'; import { ProductScrollComponent } from './product-scroll.component'; - import createSpy = jasmine.createSpy; -import { MockFeatureLevelDirective } from '../../../../../shared/test/mock-feature-level-directive'; const mockModel1: ProductSearchPage = { breadcrumbs: [ diff --git a/projects/storefrontlib/src/cms-components/product/product-list/product-grid-item/product-grid-item.component.html b/projects/storefrontlib/src/cms-components/product/product-list/product-grid-item/product-grid-item.component.html index 3852c9ad749..858441711f1 100644 --- a/projects/storefrontlib/src/cms-components/product/product-list/product-grid-item/product-grid-item.component.html +++ b/projects/storefrontlib/src/cms-components/product/product-list/product-grid-item/product-grid-item.component.html @@ -42,3 +42,6 @@ [showQuantity]="false" [product]="product" > + + + diff --git a/projects/storefrontlib/src/cms-components/product/product-list/product-grid-item/product-grid-item.component.spec.ts b/projects/storefrontlib/src/cms-components/product/product-list/product-grid-item/product-grid-item.component.spec.ts index ea8faf0321b..246eb4c749c 100644 --- a/projects/storefrontlib/src/cms-components/product/product-list/product-grid-item/product-grid-item.component.spec.ts +++ b/projects/storefrontlib/src/cms-components/product/product-list/product-grid-item/product-grid-item.component.spec.ts @@ -5,11 +5,16 @@ import { Pipe, PipeTransform, } from '@angular/core'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { ProductGridItemComponent } from './product-grid-item.component'; -import { I18nTestingModule } from '@spartacus/core'; +import { + I18nTestingModule, + ProductService, + RoutingService, +} from '@spartacus/core'; import { MockFeatureLevelDirective } from '../../../../shared/test/mock-feature-level-directive'; +import { ProductListItemContext } from '../../product-list-item-context'; +import { ProductGridItemComponent } from './product-grid-item.component'; @Component({ selector: 'cx-add-to-cart', @@ -62,6 +67,9 @@ class MockStyleIconsComponent { @Input() variants: any[]; } +class MockRoutingService {} +class MockProductService {} + describe('ProductGridItemComponent in product-list', () => { let component: ProductGridItemComponent; let fixture: ComponentFixture; @@ -96,7 +104,20 @@ describe('ProductGridItemComponent in product-list', () => { MockStyleIconsComponent, MockFeatureLevelDirective, ], + providers: [ + { + provide: RoutingService, + useClass: MockRoutingService, + }, + { + provide: ProductService, + useClass: MockProductService, + }, + ], }) + .overrideComponent(ProductGridItemComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default }, + }) .overrideComponent(ProductGridItemComponent, { set: { changeDetection: ChangeDetectionStrategy.Default }, }) @@ -109,6 +130,7 @@ describe('ProductGridItemComponent in product-list', () => { component = fixture.componentInstance; component.product = mockProduct; + component.ngOnChanges(); fixture.detectChanges(); }); @@ -173,4 +195,17 @@ describe('ProductGridItemComponent in product-list', () => { fixture.debugElement.nativeElement.querySelector('cx-add-to-cart') ).toBeNull(); }); + + it('should have defined instance of list item context', () => { + expect(component['productListItemContext']).toBeDefined(); + }); + + it('should transmit product through the item context', (done) => { + const productListItemContext: ProductListItemContext = + component['productListItemContext']; + productListItemContext.product$.subscribe((product) => { + expect(product).toBe(mockProduct); + done(); + }); + }); }); diff --git a/projects/storefrontlib/src/cms-components/product/product-list/product-grid-item/product-grid-item.component.ts b/projects/storefrontlib/src/cms-components/product/product-list/product-grid-item/product-grid-item.component.ts index 4d97e867b94..55dc1f38978 100644 --- a/projects/storefrontlib/src/cms-components/product/product-list/product-grid-item/product-grid-item.component.ts +++ b/projects/storefrontlib/src/cms-components/product/product-list/product-grid-item/product-grid-item.component.ts @@ -1,10 +1,32 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, +} from '@angular/core'; +import { + ProductListItemContext, + ProductListItemContextOwner, +} from '../../product-list-item-context'; +import { ProductListOutlets } from '../../product-outlets.model'; @Component({ selector: 'cx-product-grid-item', templateUrl: './product-grid-item.component.html', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { provide: ProductListItemContext, useClass: ProductListItemContextOwner }, + ], }) -export class ProductGridItemComponent { +export class ProductGridItemComponent implements OnChanges { + readonly Outlets = ProductListOutlets; @Input() product: any; + + constructor(protected productListItemContext: ProductListItemContext) {} + + ngOnChanges(): void { + (this.productListItemContext as ProductListItemContextOwner).setProduct( + this.product + ); + } } diff --git a/projects/storefrontlib/src/cms-components/product/product-list/product-list-item/product-list-item.component.html b/projects/storefrontlib/src/cms-components/product/product-list/product-list-item/product-list-item.component.html index 00904821ff3..08e1969b993 100644 --- a/projects/storefrontlib/src/cms-components/product/product-list/product-list-item/product-list-item.component.html +++ b/projects/storefrontlib/src/cms-components/product/product-list/product-list-item/product-list-item.component.html @@ -47,6 +47,8 @@ [showQuantity]="false" [product]="product" > + + diff --git a/projects/storefrontlib/src/cms-components/product/product-list/product-list-item/product-list-item.component.spec.ts b/projects/storefrontlib/src/cms-components/product/product-list/product-list-item/product-list-item.component.spec.ts index 0ddad820984..3fec53f065f 100644 --- a/projects/storefrontlib/src/cms-components/product/product-list/product-list-item/product-list-item.component.spec.ts +++ b/projects/storefrontlib/src/cms-components/product/product-list/product-list-item/product-list-item.component.spec.ts @@ -5,11 +5,16 @@ import { Pipe, PipeTransform, } from '@angular/core'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { ProductListItemComponent } from './product-list-item.component'; -import { I18nTestingModule } from '@spartacus/core'; +import { + I18nTestingModule, + ProductService, + RoutingService, +} from '@spartacus/core'; import { MockFeatureLevelDirective } from '../../../../shared/test/mock-feature-level-directive'; +import { ProductListItemContext } from '../../product-list-item-context'; +import { ProductListItemComponent } from './product-list-item.component'; @Component({ selector: 'cx-add-to-cart', @@ -61,6 +66,9 @@ class MockStyleIconsComponent { @Input() variants: any[]; } +class MockRoutingService {} +class MockProductService {} + describe('ProductListItemComponent in product-list', () => { let component: ProductListItemComponent; let fixture: ComponentFixture; @@ -96,7 +104,20 @@ describe('ProductListItemComponent in product-list', () => { MockStyleIconsComponent, MockFeatureLevelDirective, ], + providers: [ + { + provide: RoutingService, + useClass: MockRoutingService, + }, + { + provide: ProductService, + useClass: MockProductService, + }, + ], }) + .overrideComponent(ProductListItemComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default }, + }) .overrideComponent(ProductListItemComponent, { set: { changeDetection: ChangeDetectionStrategy.Default }, }) @@ -110,6 +131,7 @@ describe('ProductListItemComponent in product-list', () => { component.product = mockProduct; + component.ngOnChanges(); fixture.detectChanges(); }); @@ -180,4 +202,17 @@ describe('ProductListItemComponent in product-list', () => { fixture.debugElement.nativeElement.querySelector('cx-add-to-cart') ).toBeNull(); }); + + it('should have defined instance of list item context', () => { + expect(component['productListItemContext']).toBeDefined(); + }); + + it('should transmit product through the item context', (done) => { + const productListItemContext: ProductListItemContext = + component['productListItemContext']; + productListItemContext.product$.subscribe((product) => { + expect(product).toBe(mockProduct); + done(); + }); + }); }); diff --git a/projects/storefrontlib/src/cms-components/product/product-list/product-list-item/product-list-item.component.ts b/projects/storefrontlib/src/cms-components/product/product-list/product-list-item/product-list-item.component.ts index 1096832a046..94412ecee6b 100644 --- a/projects/storefrontlib/src/cms-components/product/product-list/product-list-item/product-list-item.component.ts +++ b/projects/storefrontlib/src/cms-components/product/product-list/product-list-item/product-list-item.component.ts @@ -1,10 +1,32 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, +} from '@angular/core'; +import { + ProductListItemContext, + ProductListItemContextOwner, +} from '../../product-list-item-context'; +import { ProductListOutlets } from '../../product-outlets.model'; @Component({ selector: 'cx-product-list-item', templateUrl: './product-list-item.component.html', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { provide: ProductListItemContext, useClass: ProductListItemContextOwner }, + ], }) -export class ProductListItemComponent { +export class ProductListItemComponent implements OnChanges { + readonly Outlets = ProductListOutlets; @Input() product: any; + + constructor(protected productListItemContext: ProductListItemContext) {} + + ngOnChanges(): void { + (this.productListItemContext as ProductListItemContextOwner).setProduct( + this.product + ); + } } diff --git a/projects/storefrontlib/src/cms-components/product/product-list/product-list.module.ts b/projects/storefrontlib/src/cms-components/product/product-list/product-list.module.ts index 9bb19f899cc..4b2ea330b3a 100644 --- a/projects/storefrontlib/src/cms-components/product/product-list/product-list.module.ts +++ b/projects/storefrontlib/src/cms-components/product/product-list/product-list.module.ts @@ -9,6 +9,7 @@ import { UrlModule, } from '@spartacus/core'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; +import { OutletModule } from '../../../cms-structure/outlet/outlet.module'; import { ViewConfig } from '../../../shared/config/view-config'; import { ViewConfigModule } from '../../../shared/config/view-config.module'; import { @@ -46,6 +47,7 @@ import { ProductViewComponent } from './product-view/product-view.component'; ViewConfigModule, ProductVariantsModule, FeaturesConfigModule, + OutletModule, ], providers: [ provideDefaultConfig(defaultScrollConfig), diff --git a/projects/storefrontlib/src/cms-components/product/product-outlets.model.ts b/projects/storefrontlib/src/cms-components/product/product-outlets.model.ts index ba09d1b92ea..8c42ae86b6e 100644 --- a/projects/storefrontlib/src/cms-components/product/product-outlets.model.ts +++ b/projects/storefrontlib/src/cms-components/product/product-outlets.model.ts @@ -4,3 +4,7 @@ export enum ProductDetailOutlets { SHARE = 'PDP.SHARE', SUMMARY = 'PDP.SUMMARY', } +export enum ProductListOutlets { + GRID_ITEM_END = 'PLP.GRID.ITEM.END', + LIST_ITEM_END = 'PLP.LIST.ITEM.END', +} diff --git a/projects/storefrontstyles/scss/layout/page-templates/_product-detail.scss b/projects/storefrontstyles/scss/layout/page-templates/_product-detail.scss index d604d62f947..ec3a63fd03f 100644 --- a/projects/storefrontstyles/scss/layout/page-templates/_product-detail.scss +++ b/projects/storefrontstyles/scss/layout/page-templates/_product-detail.scss @@ -12,9 +12,16 @@ grid-row-gap: 0px; grid-template-columns: 1fr 1fr; grid-template-rows: repeat(2, auto) 1fr; + @include forVersion(3.1) { + grid-template-rows: repeat(5, auto) 1fr; + } + cx-product-images { grid-column: 1; grid-row: 1 / span 5; + @include forVersion(3.1) { + grid-row: 1 / span 6; + } } cx-product-intro { diff --git a/scripts/changelog.ts b/scripts/changelog.ts index d8927a07687..9bc3fb0bc7d 100644 --- a/scripts/changelog.ts +++ b/scripts/changelog.ts @@ -78,6 +78,7 @@ export default async function run( '@spartacus/cds': 'integration-libs/cds', '@spartacus/organization': 'feature-libs/organization', '@spartacus/product': 'feature-libs/product', + '@spartacus/product-configurator': 'feature-libs/product-configurator', '@spartacus/storefinder': 'feature-libs/storefinder', '@spartacus/qualtrics': 'feature-libs/qualtrics', '@spartacus/cdc': 'integration-libs/cdc', @@ -296,13 +297,12 @@ if (typeof config.to === 'undefined') { break; case 'product': case '@spartacus/product': - case '@spartacus/product/configurators': - case '@spartacus/product/configurators/common': - case '@spartacus/product/configurators/cpq': - case '@spartacus/product/configurators/variant': - case '@spartacus/product/configurators/textfield': config.library = '@spartacus/product'; break; + case 'product-configurator': + case '@spartacus/product-configurator': + config.library = '@spartacus/product-configurator'; + break; case 'cdc': case '@spartacus/cdc': config.library = '@spartacus/cdc'; diff --git a/scripts/install/README.md b/scripts/install/README.md index 05260e8626f..2e7f6fbef19 100644 --- a/scripts/install/README.md +++ b/scripts/install/README.md @@ -39,6 +39,7 @@ Before running the main script, please ensure that: - `ANGULAR_CLI_VERSION` variable is set properly in the `config.sh` file (it must meet project's `package.json` `@angular/cli` version requirement) - `OCC_PREFIX` variable is set to proper value (depends on backend version) - `ADD_B2B_LIBS` variable is set to true in case you want to add b2b libs (`setup` and `organization` as for `3.0.0-next.1`) to the output apps +- `ADD_PRODUCT_CONFIGURATOR` variable is set to true in case you want to add the product-configurator libs to the output apps - adjust any other variables available in the `config.default.sh` if needed (by setting them up in the `config.sh`) Once ready start the installation as below: diff --git a/scripts/install/config.default.sh b/scripts/install/config.default.sh index cd3ca4a1632..847bd519e20 100644 --- a/scripts/install/config.default.sh +++ b/scripts/install/config.default.sh @@ -20,6 +20,7 @@ SPARTACUS_PROJECTS=( "core-libs/setup" "feature-libs/organization" "feature-libs/storefinder" + "feature-libs/product-configurator" "feature-libs/qualtrics" ) @@ -42,3 +43,5 @@ SSR_PORT="4100" SSR_PWA_PORT= ADD_B2B_LIBS=false + +ADD_PRODUCT_CONFIGURATOR=true diff --git a/scripts/install/run.sh b/scripts/install/run.sh index 9e37dd59dd6..63ac9680a09 100755 --- a/scripts/install/run.sh +++ b/scripts/install/run.sh @@ -98,6 +98,9 @@ function add_spartacus_csr { if [ "$ADD_B2B_LIBS" = true ] ; then ng add @spartacus/organization@${SPARTACUS_VERSION} --interactive false fi + if [ "$ADD_PRODUCT_CONFIGURATOR" = true ] ; then + ng add @spartacus/product-configurator@${SPARTACUS_VERSION} --interactive false + fi ) } @@ -106,6 +109,9 @@ function add_spartacus_ssr { if [ "$ADD_B2B_LIBS" = true ] ; then ng add @spartacus/organization@${SPARTACUS_VERSION} --interactive false fi + if [ "$ADD_PRODUCT_CONFIGURATOR" = true ] ; then + ng add @spartacus/product-configurator@${SPARTACUS_VERSION} --interactive false + fi ) } @@ -114,6 +120,9 @@ function add_spartacus_ssr_pwa { if [ "$ADD_B2B_LIBS" = true ] ; then ng add @spartacus/organization@${SPARTACUS_VERSION} --interactive false fi + if [ "$ADD_PRODUCT_CONFIGURATOR" = true ] ; then + ng add @spartacus/product-configurator@${SPARTACUS_VERSION} --interactive false + fi ) } @@ -193,6 +202,9 @@ function local_install { printh "Creating storefinder npm package" ( cd ${CLONE_DIR}/dist/storefinder && yarn publish --new-version=${SPARTACUS_VERSION} --registry=http://localhost:4873/ --no-git-tag-version ) + printh "Creating product-configurator npm package" + ( cd ${CLONE_DIR}/dist/product-configurator && yarn publish --new-version=${SPARTACUS_VERSION} --registry=http://localhost:4873/ --no-git-tag-version ) + create_apps sleep 5 diff --git a/scripts/templates/changelog.ejs b/scripts/templates/changelog.ejs index 4a821fce8a5..e560bbf7ebd 100644 --- a/scripts/templates/changelog.ejs +++ b/scripts/templates/changelog.ejs @@ -42,6 +42,7 @@ '@spartacus/cds', '@spartacus/organization', '@spartacus/product', + '@spartacus/product-configurator', '@spartacus/storefinder', '@spartacus/qualtrics', '@spartacus/cdc', diff --git a/sonar-project.properties b/sonar-project.properties index 1a92d2604fc..6ad5e95c20f 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,14 +1,14 @@ sonar.projectKey=sap_cloud-commerce-spartacus-storefront sonar.projectName=Cloud commerce spartacus storefront -sonar.sources=projects/assets,integration-libs/cds,projects/core,projects/storefrontstyles,projects/storefrontlib,projects/schematics,projects/incubator,projects/vendor,feature-libs/organization,feature-libs/product,feature-libs/storefinder,integration-libs/cdc,core-libs/setup +sonar.sources=projects/assets,integration-libs/cds,projects/core,projects/storefrontstyles,projects/storefrontlib,projects/schematics,projects/incubator,projects/vendor,feature-libs/organization,feature-libs/product,feature-libs/product-configurator,feature-libs/storefinder,integration-libs/cdc,core-libs/setup sonar.exclusions=**/node_modules/** -sonar.tests=integration-libs/cds,projects/core,projects/storefrontlib,feature-libs/organization,feature-libs/product,feature-libs/storefinder,integration-libs/cdc,core-libs/setup +sonar.tests=integration-libs/cds,projects/core,projects/storefrontlib,feature-libs/organization,feature-libs/product,feature-libs/product-configurator,feature-libs/storefinder,integration-libs/cdc,core-libs/setup sonar.test.inclusions=**/*.spec.ts -sonar.typescript.lcov.reportPaths=coverage/cds/lcov.info,coverage/core/lcov.info,coverage/storefront/lcov.info,coverage/organization/lcov.info,coverage/product/lcov.info,coverage/storefinder/lcov.info,coverage/qualtrics/lcov.info,coverage/cdc/lcov.info,coverage/setup/lcov.info -sonar.javascript.lcov.reportPaths=coverage/cds/lcov.info,coverage/core/lcov.info,coverage/storefront/lcov.info,coverage/organization/lcov.info,coverage/product/lcov.info,coverage/storefinder/lcov.info,coverage/qualtrics/lcov.info,coverage/cdc/lcov.info,coverage/setup/lcov.info +sonar.typescript.lcov.reportPaths=coverage/cds/lcov.info,coverage/core/lcov.info,coverage/storefront/lcov.info,coverage/organization/lcov.info,coverage/product/lcov.info,coverage/product-configurator/lcov.info,coverage/storefinder/lcov.info,coverage/qualtrics/lcov.info,coverage/cdc/lcov.info,coverage/setup/lcov.info +sonar.javascript.lcov.reportPaths=coverage/cds/lcov.info,coverage/core/lcov.info,coverage/storefront/lcov.info,coverage/organization/lcov.info,coverage/product/lcov.info,coverage/product-configurator/lcov.info,coverage/storefinder/lcov.info,coverage/qualtrics/lcov.info,coverage/cdc/lcov.info,coverage/setup/lcov.info sonar.typescript.tsconfigPath=tslint.json diff --git a/tsconfig.compodoc.json b/tsconfig.compodoc.json index 60475501c53..dd1fede494f 100644 --- a/tsconfig.compodoc.json +++ b/tsconfig.compodoc.json @@ -56,24 +56,39 @@ "@spartacus/organization/order-approval/root": [ "feature-libs/organization/order-approval/root/public_api" ], - "@spartacus/product/configurators/common": [ - "feature-libs/product/configurators/common/public_api" + "@spartacus/product-configurator/common/assets": [ + "feature-libs/product-configurator/common/assets/public_api" ], - "@spartacus/product/configurators/cpq": [ - "feature-libs/product/configurators/cpq/public_api" + "@spartacus/product-configurator/common": [ + "feature-libs/product-configurator/common/public_api" ], - "@spartacus/product/configurators": [ - "feature-libs/product/configurators/public_api" + "@spartacus/product-configurator": [ + "feature-libs/product-configurator/public_api" ], - "@spartacus/product/configurators/textfield": [ - "feature-libs/product/configurators/textfield/public_api" + "@spartacus/product-configurator/rulebased": [ + "feature-libs/product-configurator/rulebased/public_api" ], - "@spartacus/product/configurators/variant": [ - "feature-libs/product/configurators/variant/public_api" + "@spartacus/product-configurator/rulebased/root": [ + "feature-libs/product-configurator/rulebased/root/public_api" + ], + "@spartacus/product-configurator/textfield": [ + "feature-libs/product-configurator/textfield/public_api" + ], + "@spartacus/product-configurator/textfield/root": [ + "feature-libs/product-configurator/textfield/root/public_api" ], "@spartacus/product": [ "feature-libs/product/public_api" ], + "@spartacus/qualtrics/components": [ + "feature-libs/qualtrics/components/public_api" + ], + "@spartacus/qualtrics": [ + "feature-libs/qualtrics/public_api" + ], + "@spartacus/qualtrics/root": [ + "feature-libs/qualtrics/root/public_api" + ], "@spartacus/storefinder/assets": [ "feature-libs/storefinder/assets/public_api" ], @@ -92,15 +107,6 @@ "@spartacus/storefinder/root": [ "feature-libs/storefinder/root/public_api" ], - "@spartacus/qualtrics": [ - "feature-libs/qualtrics/public_api" - ], - "@spartacus/qualtrics/root": [ - "feature-libs/qualtrics/root/public_api" - ], - "@spartacus/qualtrics/components": [ - "feature-libs/qualtrics/components/public_api" - ], "@spartacus/cdc": [ "integration-libs/cdc/public_api" ], diff --git a/tsconfig.json b/tsconfig.json index a37442f47d5..64506a97a82 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -62,24 +62,30 @@ "@spartacus/organization/order-approval/root": [ "feature-libs/organization/order-approval/root/public_api" ], - "@spartacus/product/configurators/common": [ - "feature-libs/product/configurators/common/public_api" - ], - "@spartacus/product/configurators/cpq": [ - "feature-libs/product/configurators/cpq/public_api" - ], - "@spartacus/product/configurators": [ - "feature-libs/product/configurators/public_api" - ], - "@spartacus/product/configurators/textfield": [ - "feature-libs/product/configurators/textfield/public_api" - ], - "@spartacus/product/configurators/variant": [ - "feature-libs/product/configurators/variant/public_api" - ], "@spartacus/product": [ "feature-libs/product/public_api" ], + "@spartacus/product-configurator": [ + "feature-libs/product-configurator/public_api" + ], + "@spartacus/product-configurator/common": [ + "feature-libs/product-configurator/common/public_api" + ], + "@spartacus/product-configurator/common/assets": [ + "feature-libs/product-configurator/common/assets/public_api" + ], + "@spartacus/product-configurator/textfield": [ + "feature-libs/product-configurator/textfield/public_api" + ], + "@spartacus/product-configurator/textfield/root": [ + "feature-libs/product-configurator/textfield/root/public_api" + ], + "@spartacus/product-configurator/rulebased": [ + "feature-libs/product-configurator/rulebased/public_api" + ], + "@spartacus/product-configurator/rulebased/root": [ + "feature-libs/product-configurator/rulebased/root/public_api" + ], "@spartacus/storefinder/assets": [ "feature-libs/storefinder/assets/public_api" ], From 5519d0fce7cc9796ca3290595a2705921366fe15 Mon Sep 17 00:00:00 2001 From: pla Date: Wed, 27 Jan 2021 18:37:01 -0500 Subject: [PATCH 29/30] Refactor: Refactor to support typescript strict mode compilation in feature libs. (GH-10885)(#10889) --- .../user-auth/services/auth-http-header.service.ts | 2 +- projects/core/src/cms/config/cms-config.ts | 2 +- .../src/cms/page/routing/route-page-meta.model.ts | 14 ++++++++------ .../src/occ/adapters/product/product-occ-config.ts | 2 +- .../core/src/occ/config/loading-scopes-config.ts | 4 ++-- .../core/src/occ/occ-models/occ-endpoints.model.ts | 2 +- .../external-routes/external-routes.service.ts | 2 +- .../src/site-context/config/site-context-config.ts | 2 +- .../core/src/state/utils/loader/loader.reducer.ts | 2 +- 9 files changed, 17 insertions(+), 15 deletions(-) diff --git a/projects/core/src/auth/user-auth/services/auth-http-header.service.ts b/projects/core/src/auth/user-auth/services/auth-http-header.service.ts index 87a6da8c9ae..f44e227b33d 100644 --- a/projects/core/src/auth/user-auth/services/auth-http-header.service.ts +++ b/projects/core/src/auth/user-auth/services/auth-http-header.service.ts @@ -59,7 +59,7 @@ export class AuthHttpHeaderService { return rawValue; } - protected createAuthorizationHeader(): { Authorization?: string } { + protected createAuthorizationHeader(): { Authorization: string } | {} { let token; this.authStorageService .getToken() diff --git a/projects/core/src/cms/config/cms-config.ts b/projects/core/src/cms/config/cms-config.ts index 19d4bb08c64..30fa8478d02 100644 --- a/projects/core/src/cms/config/cms-config.ts +++ b/projects/core/src/cms/config/cms-config.ts @@ -86,7 +86,7 @@ export enum DeferLoadingStrategy { export interface CMSComponentConfig extends StandardCmsComponentConfig, JspIncludeCmsComponentConfig { - [componentType: string]: CmsComponentMapping; + [componentType: string]: CmsComponentMapping | undefined; } export interface FeatureModuleConfig { diff --git a/projects/core/src/cms/page/routing/route-page-meta.model.ts b/projects/core/src/cms/page/routing/route-page-meta.model.ts index 3a89d532bb4..5c34858f92e 100644 --- a/projects/core/src/cms/page/routing/route-page-meta.model.ts +++ b/projects/core/src/cms/page/routing/route-page-meta.model.ts @@ -1,5 +1,5 @@ import { Type } from '@angular/core'; -import { ActivatedRouteSnapshot } from '@angular/router'; +import { ActivatedRouteSnapshot, Data, Route } from '@angular/router'; import { Observable } from 'rxjs'; import { BreadcrumbMeta } from '../../model/page.model'; @@ -9,11 +9,13 @@ import { BreadcrumbMeta } from '../../model/page.model'; */ export interface ActivatedRouteSnapshotWithPageMeta extends ActivatedRouteSnapshot { - routeConfig: ActivatedRouteSnapshot['routeConfig'] & { - data?: ActivatedRouteSnapshot['routeConfig']['data'] & { - cxPageMeta?: RoutePageMetaConfig; - }; - }; + routeConfig: + | (Route & { + data?: Data & { + cxPageMeta?: RoutePageMetaConfig; + }; + }) + | null; } /** diff --git a/projects/core/src/occ/adapters/product/product-occ-config.ts b/projects/core/src/occ/adapters/product/product-occ-config.ts index 4488bea2ce7..924fa414491 100644 --- a/projects/core/src/occ/adapters/product/product-occ-config.ts +++ b/projects/core/src/occ/adapters/product/product-occ-config.ts @@ -16,7 +16,7 @@ export interface ProductScopesConfig extends LoadingScopesConfig { details?: ProductLoadingScopeConfig; attributes?: ProductLoadingScopeConfig; variants?: ProductLoadingScopeConfig; - [scope: string]: ProductLoadingScopeConfig; + [scope: string]: ProductLoadingScopeConfig | undefined; } export interface ProductLoadingScopeConfig extends LoadingScopeConfig { diff --git a/projects/core/src/occ/config/loading-scopes-config.ts b/projects/core/src/occ/config/loading-scopes-config.ts index 9421a98975a..b6a35a3ae4a 100644 --- a/projects/core/src/occ/config/loading-scopes-config.ts +++ b/projects/core/src/occ/config/loading-scopes-config.ts @@ -10,9 +10,9 @@ export interface LoadingScopeConfig { } export interface LoadingScopesConfig { - [scope: string]: LoadingScopeConfig; + [scope: string]: LoadingScopeConfig | undefined; } export interface LoadingScopes { - [model: string]: LoadingScopesConfig; + [model: string]: LoadingScopesConfig | undefined; } diff --git a/projects/core/src/occ/occ-models/occ-endpoints.model.ts b/projects/core/src/occ/occ-models/occ-endpoints.model.ts index 470d0535782..5eccc77215f 100644 --- a/projects/core/src/occ/occ-models/occ-endpoints.model.ts +++ b/projects/core/src/occ/occ-models/occ-endpoints.model.ts @@ -2,7 +2,7 @@ export const DEFAULT_SCOPE = 'default'; export interface OccEndpoint { default?: string; - [scope: string]: string; + [scope: string]: string | undefined; } export interface ProductOccEndpoint extends OccEndpoint { diff --git a/projects/core/src/routing/external-routes/external-routes.service.ts b/projects/core/src/routing/external-routes/external-routes.service.ts index 4684b726e40..75e90abd42b 100644 --- a/projects/core/src/routing/external-routes/external-routes.service.ts +++ b/projects/core/src/routing/external-routes/external-routes.service.ts @@ -17,7 +17,7 @@ export class ExternalRoutesService { protected injector: Injector ) {} - protected get internalUrlPatterns(): ExternalRoutesConfig['routing']['internal'] { + protected get internalUrlPatterns(): string[] { return ( (this.config && this.config.routing && this.config.routing.internal) || [] ); diff --git a/projects/core/src/site-context/config/site-context-config.ts b/projects/core/src/site-context/config/site-context-config.ts index 78488986136..e5793b21fad 100644 --- a/projects/core/src/site-context/config/site-context-config.ts +++ b/projects/core/src/site-context/config/site-context-config.ts @@ -8,6 +8,6 @@ import { Config } from '../../config/config-tokens'; export abstract class SiteContextConfig { context?: { urlParameters?: string[]; - [contextName: string]: string[]; + [contextName: string]: string[] | undefined; }; } diff --git a/projects/core/src/state/utils/loader/loader.reducer.ts b/projects/core/src/state/utils/loader/loader.reducer.ts index ba1618385f2..4b4e20abb62 100644 --- a/projects/core/src/state/utils/loader/loader.reducer.ts +++ b/projects/core/src/state/utils/loader/loader.reducer.ts @@ -18,7 +18,7 @@ export const initialLoaderState: LoaderState = { export function loaderReducer( entityType: string, reducer?: (state: T, action: Action) => T -): (state: LoaderState, action: LoaderAction) => LoaderState { +): (state: LoaderState | undefined, action: LoaderAction) => LoaderState { return ( state: LoaderState = initialLoaderState, action: LoaderAction From 36ea1d5b7c63aa067014a7a6001091382269ec5d Mon Sep 17 00:00:00 2001 From: Marcin Lasak Date: Thu, 28 Jan 2021 18:08:09 +0100 Subject: [PATCH 30/30] chore: Release 3.1.0-next.0 (#10921) Closes #10917 --- .github/ISSUE_TEMPLATE/new-release.md | 3 ++- core-libs/setup/package.json | 6 +++--- feature-libs/organization/package.json | 8 ++++---- feature-libs/product-configurator/.release-it.json | 4 +++- feature-libs/product-configurator/package.json | 10 +++++----- feature-libs/qualtrics/package.json | 6 +++--- feature-libs/storefinder/package.json | 8 ++++---- integration-libs/{ => cdc}/.release-it.json | 0 integration-libs/cdc/package.json | 6 +++--- integration-libs/cds/package.json | 6 +++--- projects/assets/package.json | 2 +- projects/core/package.json | 2 +- projects/schematics/package.json | 2 +- projects/schematics/src/migrations/migrations.json | 12 ++++++------ projects/storefrontlib/package.json | 4 ++-- projects/storefrontstyles/package.json | 2 +- scripts/release-it/bumper.js | 11 +++++------ 17 files changed, 47 insertions(+), 45 deletions(-) rename integration-libs/{ => cdc}/.release-it.json (100%) diff --git a/.github/ISSUE_TEMPLATE/new-release.md b/.github/ISSUE_TEMPLATE/new-release.md index 101e2e6a35d..32247feb2e7 100644 --- a/.github/ISSUE_TEMPLATE/new-release.md +++ b/.github/ISSUE_TEMPLATE/new-release.md @@ -82,7 +82,8 @@ To keep track of spartacussampledata releases, we keep a `latest` branch on each - [ ] `npm run release:setup:with-changelog` (needed since `3.0.0-next.1`) - [ ] `npm run release:organization:with-changelog` (needed since `3.0.0-next.1`) - [ ] `npm run release:storefinder:with-changelog` (needed since `3.0.0-rc.0`) - - [ ] `npm run release:qualtrics:with-changelog` (needed since `3.1.0`) + - [ ] `npm run release:qualtrics:with-changelog` (needed since `3.1.0-next.0`) + - [ ] `npm run release:product-configurator:with-changelog` (needed since `3.1.0-next.0`) - [ ] `npm run release:cdc:with-changelog` (since 2.1.0-next.0 - publish under `0..0` eg. `0.201.0-next.0` for first `2.1.0-next.0` release) - [ ] before the script set the spartacus peerDependencies manually (as we publish it under 0.201.0-next.0 version) - [ ] Check that the release notes are populated on github (if they are not, update them) diff --git a/core-libs/setup/package.json b/core-libs/setup/package.json index 6fc1e8fc709..c525544b5e8 100644 --- a/core-libs/setup/package.json +++ b/core-libs/setup/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/setup", - "version": "3.0.0", + "version": "3.1.0-next.0", "description": "Includes features that makes Spartacus and it's setup easier and streamlined.", "keywords": [ "spartacus", @@ -18,8 +18,8 @@ "peerDependencies": { "@angular/common": "^10.1.0", "@angular/core": "^10.1.0", - "@spartacus/core": "3.0.0", - "@spartacus/storefront": "3.0.0" + "@spartacus/core": "3.1.0-next.0", + "@spartacus/storefront": "3.1.0-next.0" }, "optionalDependencies": { "@nguniversal/express-engine": "^10.1.0", diff --git a/feature-libs/organization/package.json b/feature-libs/organization/package.json index 4812a649b02..175542c1b35 100644 --- a/feature-libs/organization/package.json +++ b/feature-libs/organization/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/organization", - "version": "3.0.0", + "version": "3.1.0-next.0", "description": "Organization library for Spartacus", "keywords": [ "spartacus", @@ -29,9 +29,9 @@ "@ngrx/effects": "^10.0.0", "@ngrx/store": "^10.0.0", "@schematics/angular": "^10.1.0", - "@spartacus/core": "3.0.0", - "@spartacus/schematics": "3.0.0", - "@spartacus/storefront": "3.0.0", + "@spartacus/core": "3.1.0-next.0", + "@spartacus/schematics": "3.1.0-next.0", + "@spartacus/storefront": "3.1.0-next.0", "bootstrap": "^4.0", "rxjs": "^6.6.0", "typescript": "~4.0.2" diff --git a/feature-libs/product-configurator/.release-it.json b/feature-libs/product-configurator/.release-it.json index 3fff21921a5..379008fe82a 100644 --- a/feature-libs/product-configurator/.release-it.json +++ b/feature-libs/product-configurator/.release-it.json @@ -25,7 +25,9 @@ "file": "package.json", "path": [ "peerDependencies.@spartacus/core", - "peerDependencies.@spartacus/storefront" + "peerDependencies.@spartacus/storefront", + "peerDependencies.@spartacus/schematics", + "peerDependencies.@spartacus/styles" ] } ] diff --git a/feature-libs/product-configurator/package.json b/feature-libs/product-configurator/package.json index 7590b972aa1..26d26d040bf 100644 --- a/feature-libs/product-configurator/package.json +++ b/feature-libs/product-configurator/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/product-configurator", - "version": "3.0.0-next.6", + "version": "3.1.0-next.0", "description": "Product configurator feature library for Spartacus", "keywords": [ "spartacus", @@ -29,10 +29,10 @@ "@ngrx/effects": "^10.0.0", "@ngrx/store": "^10.0.0", "@schematics/angular": "10.2.1", - "@spartacus/core": "3.0.0", - "@spartacus/schematics": "3.0.0", - "@spartacus/storefront": "3.0.0", - "@spartacus/styles": "3.0.0", + "@spartacus/core": "3.1.0-next.0", + "@spartacus/schematics": "3.1.0-next.0", + "@spartacus/storefront": "3.1.0-next.0", + "@spartacus/styles": "3.1.0-next.0", "rxjs": "^6.6.0" }, "publishConfig": { diff --git a/feature-libs/qualtrics/package.json b/feature-libs/qualtrics/package.json index 36c5d075f5c..cfb220c1c63 100644 --- a/feature-libs/qualtrics/package.json +++ b/feature-libs/qualtrics/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/qualtrics", - "version": "3.1.0", + "version": "3.1.0-next.0", "description": "Qualtrics library for Spartacus", "keywords": [ "spartacus", @@ -26,8 +26,8 @@ "@angular/common": "^10.1.0", "@angular/core": "^10.1.0", "@schematics/angular": "^10.1.0", - "@spartacus/core": "3.0.0", - "@spartacus/schematics": "3.0.0", + "@spartacus/core": "3.1.0-next.0", + "@spartacus/schematics": "3.1.0-next.0", "bootstrap": "^4.0", "rxjs": "^6.6.0" }, diff --git a/feature-libs/storefinder/package.json b/feature-libs/storefinder/package.json index 580e0a7bfd3..fae6eee9d3c 100644 --- a/feature-libs/storefinder/package.json +++ b/feature-libs/storefinder/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/storefinder", - "version": "3.0.0", + "version": "3.1.0-next.0", "description": "Store finder feature library for Spartacus", "keywords": [ "spartacus", @@ -29,9 +29,9 @@ "@ngrx/effects": "^10.0.0", "@ngrx/store": "^10.0.0", "@schematics/angular": "^10.1.0", - "@spartacus/core": "3.0.0", - "@spartacus/schematics": "3.0.0", - "@spartacus/storefront": "3.0.0", + "@spartacus/core": "3.1.0-next.0", + "@spartacus/schematics": "3.1.0-next.0", + "@spartacus/storefront": "3.1.0-next.0", "bootstrap": "^4.3.1", "rxjs": "^6.6.0" }, diff --git a/integration-libs/.release-it.json b/integration-libs/cdc/.release-it.json similarity index 100% rename from integration-libs/.release-it.json rename to integration-libs/cdc/.release-it.json diff --git a/integration-libs/cdc/package.json b/integration-libs/cdc/package.json index 6c2d1870c57..6cae231ef47 100644 --- a/integration-libs/cdc/package.json +++ b/integration-libs/cdc/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/cdc", - "version": "0.300.0-next.6", + "version": "0.301.0-next.0", "description": "Customer Data Cloud Integration library for Spartacus", "keywords": [ "spartacus", @@ -22,8 +22,8 @@ "@angular/router": "^10.1.0", "@ngrx/effects": "^10.0.0", "@ngrx/store": "^10.0.0", - "@spartacus/core": "3.0.0", - "@spartacus/storefront": "3.0.0", + "@spartacus/core": "3.1.0-next.0", + "@spartacus/storefront": "3.1.0-next.0", "rxjs": "^6.6.0" }, "publishConfig": { diff --git a/integration-libs/cds/package.json b/integration-libs/cds/package.json index 6ed8f754df9..55d9a29d2f0 100644 --- a/integration-libs/cds/package.json +++ b/integration-libs/cds/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/cds", - "version": "3.0.0-next.6", + "version": "3.1.0-next.0", "description": "Context Driven Service library for Spartacus", "keywords": [ "spartacus", @@ -22,8 +22,8 @@ "@angular/core": "^10.1.0", "@angular/router": "^10.1.0", "@ngrx/store": "^10.0.0", - "@spartacus/core": "3.0.0", - "@spartacus/storefront": "3.0.0", + "@spartacus/core": "3.1.0-next.0", + "@spartacus/storefront": "3.1.0-next.0", "rxjs": "^6.6.0" }, "publishConfig": { diff --git a/projects/assets/package.json b/projects/assets/package.json index 4832b580298..3b99d7b7b17 100644 --- a/projects/assets/package.json +++ b/projects/assets/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/assets", - "version": "3.0.0", + "version": "3.1.0-next.0", "homepage": "https://github.com/SAP/spartacus", "repository": "https://github.com/SAP/spartacus/tree/develop/projects/assets", "scripts": { diff --git a/projects/core/package.json b/projects/core/package.json index 3f9c58315f1..3d0debc979e 100644 --- a/projects/core/package.json +++ b/projects/core/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/core", - "version": "3.0.0", + "version": "3.1.0-next.0", "description": "Spartacus - the core framework", "keywords": [ "spartacus", diff --git a/projects/schematics/package.json b/projects/schematics/package.json index 9eb7b9054cc..af9e363141c 100644 --- a/projects/schematics/package.json +++ b/projects/schematics/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/schematics", - "version": "3.0.0", + "version": "3.1.0-next.0", "description": "Spartacus schematics", "keywords": [ "spartacus", diff --git a/projects/schematics/src/migrations/migrations.json b/projects/schematics/src/migrations/migrations.json index 23f006a008f..108dcbfd40a 100644 --- a/projects/schematics/src/migrations/migrations.json +++ b/projects/schematics/src/migrations/migrations.json @@ -47,32 +47,32 @@ }, "migration-v3-component-deprecations-01": { - "version": "3.0.0", + "version": "3.1.0-next.0", "factory": "./3_0/component-deprecations/component-deprecations#migrate", "description": "Handle deprecated Spartacus components" }, "migration-v3-config-deprecations-02": { - "version": "3.0.0", + "version": "3.1.0-next.0", "factory": "./3_0/config-deprecations/config-deprecation#migrate", "description": "Handle deprecated configuration properties" }, "migration-v3-constructor-deprecations-03": { - "version": "3.0.0", + "version": "3.1.0-next.0", "factory": "./3_0/constructor-deprecations/constructor-deprecations#migrate", "description": "Add or remove constructor parameters" }, "migration-v3-methods-and-properties-deprecations-04": { - "version": "3.0.0", + "version": "3.1.0-next.0", "factory": "./3_0/methods-and-properties-deprecations/methods-and-properties-deprecations#migrate", "description": "Comment about usage of remove public methods or properties" }, "migration-v3-removed-public-api-deprecation-05": { - "version": "3.0.0", + "version": "3.1.0-next.0", "factory": "./3_0/removed-public-api-deprecations/removed-public-api-deprecation#migrate", "description": "Comment about usage of removed public api" }, "migration-v3-css-06": { - "version": "3.0.0", + "version": "3.1.0-next.0", "factory": "./3_0/css/css#migrate", "description": "Handle deprecated CSS" }, diff --git a/projects/storefrontlib/package.json b/projects/storefrontlib/package.json index c7c6e9ed07f..1d5a49ada70 100644 --- a/projects/storefrontlib/package.json +++ b/projects/storefrontlib/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/storefront", - "version": "3.0.0", + "version": "3.1.0-next.0", "keywords": [ "spartacus", "storefront", @@ -24,7 +24,7 @@ "@ngrx/effects": "^10.0.0", "@ngrx/router-store": "^10.0.0", "@ngrx/store": "^10.0.0", - "@spartacus/core": "3.0.0", + "@spartacus/core": "3.1.0-next.0", "ngx-infinite-scroll": "^8.0.0", "rxjs": "^6.6.0" }, diff --git a/projects/storefrontstyles/package.json b/projects/storefrontstyles/package.json index 3d9b18ba155..36717a8d14c 100644 --- a/projects/storefrontstyles/package.json +++ b/projects/storefrontstyles/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/styles", - "version": "3.0.0", + "version": "3.1.0-next.0", "description": "Style library containing global styles", "keywords": [ "spartacus", diff --git a/scripts/release-it/bumper.js b/scripts/release-it/bumper.js index 19b67e85691..d775ddff783 100644 --- a/scripts/release-it/bumper.js +++ b/scripts/release-it/bumper.js @@ -1,7 +1,6 @@ const fs = require('fs'); const util = require('util'); -const get = require('lodash.get'); -const set = require('lodash.set'); +const _ = require('lodash'); const { Plugin } = require('release-it'); const readFile = util.promisify(fs.readFile); @@ -23,14 +22,14 @@ class Bumper extends Plugin { if (file) { const data = await readFile(file); const parsed = JSON.parse(data); - version = get(parsed, path); + version = _.get(parsed, path); } return version; } bump(version) { const { out } = this.options; - const { isDryRun } = this.global; + const { isDryRun } = this.config; if (!out) return; return Promise.all( out.map(async (out) => { @@ -44,10 +43,10 @@ class Bumper extends Plugin { const indent = ' '; const parsed = JSON.parse(data); if (typeof path === 'string') { - set(parsed, path, version); + _.set(parsed, path, version); } else { path.map((path) => { - set(parsed, path, version); + _.set(parsed, path, version); }); } return writeFile(file, JSON.stringify(parsed, null, indent) + '\n');