From 28eb2ce825bca207fd55e478deb1fa821dd9e297 Mon Sep 17 00:00:00 2001 From: Robert Goepfert Date: Thu, 8 Dec 2022 09:10:55 +0100 Subject: [PATCH] feat: add Address Doctor integration (#1337) * introduce notifier service to notify address doctor for new address check * use feature event service to notify extensions for further actions from the outside * use formly for address doctor form * mapper to map address doctor data to Intershop compatible Address interface * implement caching functionality to avoid unnecessary REST requests * add additional documentation for address doctor * introduce helper to check addresses for equality * don't open modal when no suggestions are available * update existing address form with selected address before submitting Co-authored-by: Marcel Eisentraut Co-authored-by: Stefan Hauke --- .dockerignore | 3 + docs/README.md | 3 +- docs/guides/address-doctor.md | 64 +++ docs/guides/migrations.md | 2 + docs/guides/ssr-startup.md | 1 + .../address-doctor/address-doctor.module.ts | 22 + .../address-doctor/exports/.gitignore | 1 + .../exports/address-doctor-exports.module.ts | 12 + .../facades/address-doctor.facade.ts | 32 ++ .../address-doctor-config.model.ts | 6 + .../address-doctor-event.model.ts | 5 + .../address-doctor/address-doctor.helper.ts | 16 + .../address-doctor.interface.ts | 68 +++ .../address-doctor/address-doctor.mapper.ts | 23 + .../address-doctor-events.service.ts | 38 ++ .../address-doctor.service.spec.ts | 248 +++++++++ .../address-doctor/address-doctor.service.ts | 118 +++++ .../address-doctor-modal.component.html | 24 + .../address-doctor-modal.component.spec.ts | 125 +++++ .../address-doctor-modal.component.ts | 121 +++++ .../address-doctor.component.html | 6 + .../address-doctor.component.spec.ts | 61 +++ .../address-doctor.component.ts | 100 ++++ .../account-addresses.component.html | 2 + .../account-addresses.component.spec.ts | 482 ++++++++++-------- .../account-addresses.component.ts | 45 +- .../registration-page.component.html | 2 + .../registration-page.component.spec.ts | 11 +- .../registration-page.component.ts | 48 +- ...sket-invoice-address-widget.component.html | 4 + ...t-invoice-address-widget.component.spec.ts | 13 +- ...basket-invoice-address-widget.component.ts | 46 +- ...ket-shipping-address-widget.component.html | 4 + ...-shipping-address-widget.component.spec.ts | 13 +- ...asket-shipping-address-widget.component.ts | 46 +- src/app/shared/shared.module.ts | 2 + src/assets/i18n/de_DE.json | 5 + src/assets/i18n/en_US.json | 5 + src/assets/i18n/fr_FR.json | 5 + src/environments/environment.model.ts | 5 + 40 files changed, 1614 insertions(+), 223 deletions(-) create mode 100644 docs/guides/address-doctor.md create mode 100644 src/app/extensions/address-doctor/address-doctor.module.ts create mode 100644 src/app/extensions/address-doctor/exports/.gitignore create mode 100644 src/app/extensions/address-doctor/exports/address-doctor-exports.module.ts create mode 100644 src/app/extensions/address-doctor/facades/address-doctor.facade.ts create mode 100644 src/app/extensions/address-doctor/models/address-doctor/address-doctor-config.model.ts create mode 100644 src/app/extensions/address-doctor/models/address-doctor/address-doctor-event.model.ts create mode 100644 src/app/extensions/address-doctor/models/address-doctor/address-doctor.helper.ts create mode 100644 src/app/extensions/address-doctor/models/address-doctor/address-doctor.interface.ts create mode 100644 src/app/extensions/address-doctor/models/address-doctor/address-doctor.mapper.ts create mode 100644 src/app/extensions/address-doctor/services/address-doctor-events/address-doctor-events.service.ts create mode 100644 src/app/extensions/address-doctor/services/address-doctor/address-doctor.service.spec.ts create mode 100644 src/app/extensions/address-doctor/services/address-doctor/address-doctor.service.ts create mode 100644 src/app/extensions/address-doctor/shared/address-doctor-modal/address-doctor-modal.component.html create mode 100644 src/app/extensions/address-doctor/shared/address-doctor-modal/address-doctor-modal.component.spec.ts create mode 100644 src/app/extensions/address-doctor/shared/address-doctor-modal/address-doctor-modal.component.ts create mode 100644 src/app/extensions/address-doctor/shared/address-doctor/address-doctor.component.html create mode 100644 src/app/extensions/address-doctor/shared/address-doctor/address-doctor.component.spec.ts create mode 100644 src/app/extensions/address-doctor/shared/address-doctor/address-doctor.component.ts diff --git a/.dockerignore b/.dockerignore index 94ff065cfb..cff0915457 100644 --- a/.dockerignore +++ b/.dockerignore @@ -90,6 +90,9 @@ # from projects/requisition-management/src/app/exports/.gitignore /projects/requisition-management/src/app/exports/**/lazy* +# from src/app/extensions/address-doctor/exports/.gitignore +/src/app/extensions/address-doctor/exports/**/lazy* + # from src/app/extensions/compare/exports/.gitignore /src/app/extensions/compare/exports/**/lazy* diff --git a/docs/README.md b/docs/README.md index 91c2b50c75..99a125b5f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -80,8 +80,9 @@ kb_sync_latest_only ### Third-party Integrations -- [Guide - Google Tag Manager](./guides/google-tag-manager.md) +- [Guide - Tracking with Google Tag Manager](./guides/google-tag-manager.md) - [Guide - Client-Side Error Monitoring with Sentry](./guides/sentry-error-monitoring.md) - [Guide - Extended Product Configurations with Tacton](./guides/tacton-product-configuration.md) - [Guide - Monitoring with Prometheus](./guides/prometheus-monitoring.md) - [Guide - Store Locator with Google Maps](./guides/store-locator.md) +- [Guide - Address Check with Address Doctor](./guides/address-doctor.md) diff --git a/docs/guides/address-doctor.md b/docs/guides/address-doctor.md new file mode 100644 index 0000000000..fb127010bf --- /dev/null +++ b/docs/guides/address-doctor.md @@ -0,0 +1,64 @@ + + +# Address Check with Address Doctor + +We integrated [Address Doctor](https://www.informatica.com/de/products/data-quality/data-as-a-service/address-verification.html) to verify address data for correctness. + +## Setup + +First, activate the feature toggle `addressDoctor`. +You will also have to provide the endpoint and additional verification data. +This can be done by defining it in [Angular CLI environment](../concepts/configuration.md#angular-cli-environments) files: + +```typescript +export const environment: Environment = { + ...ENVIRONMENT_DEFAULTS, + + addressDoctor: { + url: '', + login: '', + password: '', + maxResultCount: 5, + }, +``` + +This configuration can also be supplied via environment variable `ADDRESS_DOCTOR` as stringified JSON: + +```text +ADDRESS_DOCTOR='{ "addressDoctor": { "url": "", "login": "", "password": "", "maxResultCount": "5" } }'; +``` + +## Workflow + +To check an address with the address doctor the PWA needs to render the `` component. +When the user submits the address data, the PWA needs to send a [feature notification event](../../src/app/core/utils/feature-event/feature-event.service.ts) with the request to check the data. + +```typescript +const id = this.featureEventService.sendNotification('addressDoctor', 'check-address', { + address, +}); +``` + +This method will submit the address data to the Address Doctor REST API and open a modal with all suggestions. +The user needs to decide and confirm the address, which is the correct one. +With the subscribe on the [eventResultListener$](../../src/app/core/utils/feature-event/feature-event.service.ts) observable, the initial component can react on the confirmation data. + +```typescript +this.featureEventService + .eventResultListener$('addressDoctor', 'check-address', id) + .pipe(whenTruthy(), take(1), takeUntil(this.destroy$)) + .subscribe(({ address }) => { + if (address) { + this.accountFacade.updateCustomerAddress(address); + } + }); +``` + +## Further References + +- [Concept - Configuration](../concepts/configuration.md) diff --git a/docs/guides/migrations.md b/docs/guides/migrations.md index aab02acaf9..28fb6749f6 100644 --- a/docs/guides/migrations.md +++ b/docs/guides/migrations.md @@ -34,6 +34,8 @@ The `getOAuthServiceInstance()` static method from the `InstanceCreators` class Furthermore the handling of the anonymous user token has been changed. It will only be fetched when an anonymous user intends to create a basket. +We added an Address Doctor integration as a new extension which can be enabled with the feature toggle `addressDoctor` and [additional configuration](./address-doctor.md). + ## 3.3 to 4.0 The Intershop PWA now uses Node.js 18.15.0 LTS with the corresponding npm version 9.5.0 and the `"lockfileVersion": 3,`. diff --git a/docs/guides/ssr-startup.md b/docs/guides/ssr-startup.md index 52c97d1ca7..e95cd44550 100644 --- a/docs/guides/ssr-startup.md +++ b/docs/guides/ssr-startup.md @@ -68,6 +68,7 @@ Make sure to use them as written in the table below. | | PROMETHEUS | switch | Exposes Prometheus metrics | | | IDENTITY_PROVIDER | string | ID of the default identity provider if other than `ICM` | | | IDENTITY_PROVIDERS | JSON | Configuration of additional identity providers besides the default `ICM` | +| | ADDRESS_DOCTOR | JSON | Configuration of address doctor with login, password, maxResultCount and url | ## Development diff --git a/src/app/extensions/address-doctor/address-doctor.module.ts b/src/app/extensions/address-doctor/address-doctor.module.ts new file mode 100644 index 0000000000..16574340c6 --- /dev/null +++ b/src/app/extensions/address-doctor/address-doctor.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; + +import { FEATURE_EVENT_RESULT_LISTENER } from 'ish-core/utils/feature-event/feature-event.service'; +import { SharedModule } from 'ish-shared/shared.module'; + +import { AddressDoctorEventsService } from './services/address-doctor-events/address-doctor-events.service'; +import { AddressDoctorModalComponent } from './shared/address-doctor-modal/address-doctor-modal.component'; +import { AddressDoctorComponent } from './shared/address-doctor/address-doctor.component'; + +@NgModule({ + imports: [SharedModule], + declarations: [AddressDoctorComponent, AddressDoctorModalComponent], + exports: [SharedModule], + providers: [ + { + provide: FEATURE_EVENT_RESULT_LISTENER, + useFactory: AddressDoctorEventsService.checkAddressResultListenerFactory, + multi: true, + }, + ], +}) +export class AddressDoctorModule {} diff --git a/src/app/extensions/address-doctor/exports/.gitignore b/src/app/extensions/address-doctor/exports/.gitignore new file mode 100644 index 0000000000..c9c09b831c --- /dev/null +++ b/src/app/extensions/address-doctor/exports/.gitignore @@ -0,0 +1 @@ +**/lazy* diff --git a/src/app/extensions/address-doctor/exports/address-doctor-exports.module.ts b/src/app/extensions/address-doctor/exports/address-doctor-exports.module.ts new file mode 100644 index 0000000000..47fdd5c84f --- /dev/null +++ b/src/app/extensions/address-doctor/exports/address-doctor-exports.module.ts @@ -0,0 +1,12 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { LazyAddressDoctorComponent } from './lazy-address-doctor/lazy-address-doctor.component'; + +@NgModule({ + imports: [CommonModule, TranslateModule], + declarations: [LazyAddressDoctorComponent], + exports: [LazyAddressDoctorComponent], +}) +export class AddressDoctorExportsModule {} diff --git a/src/app/extensions/address-doctor/facades/address-doctor.facade.ts b/src/app/extensions/address-doctor/facades/address-doctor.facade.ts new file mode 100644 index 0000000000..e343985dd8 --- /dev/null +++ b/src/app/extensions/address-doctor/facades/address-doctor.facade.ts @@ -0,0 +1,32 @@ +import { Injectable, inject } from '@angular/core'; +import { isEqual } from 'lodash-es'; +import { Observable, map, of, race, tap, timer } from 'rxjs'; + +import { Address } from 'ish-core/models/address/address.model'; + +import { AddressDoctorService } from '../services/address-doctor/address-doctor.service'; + +@Injectable({ providedIn: 'root' }) +export class AddressDoctorFacade { + private addressDoctorService = inject(AddressDoctorService); + + private lastAddressCheck: Address; + private lastAddressCheckResult: Address[] = []; + + checkAddress(address: Address): Observable { + if (isEqual(address, this.lastAddressCheck)) { + return of(this.lastAddressCheckResult); + } + + this.lastAddressCheck = address; + return race( + this.addressDoctorService.postAddress(address).pipe( + tap(result => { + this.lastAddressCheckResult = result; + }) + ), + // if the address check takes longer than 5 seconds return with no suggestions + timer(5000).pipe(map(() => [])) + ); + } +} diff --git a/src/app/extensions/address-doctor/models/address-doctor/address-doctor-config.model.ts b/src/app/extensions/address-doctor/models/address-doctor/address-doctor-config.model.ts new file mode 100644 index 0000000000..99ef138a00 --- /dev/null +++ b/src/app/extensions/address-doctor/models/address-doctor/address-doctor-config.model.ts @@ -0,0 +1,6 @@ +export interface AddressDoctorConfig { + url: string; + login: string; + password: string; + maxResultCount: number; +} diff --git a/src/app/extensions/address-doctor/models/address-doctor/address-doctor-event.model.ts b/src/app/extensions/address-doctor/models/address-doctor/address-doctor-event.model.ts new file mode 100644 index 0000000000..0963de29de --- /dev/null +++ b/src/app/extensions/address-doctor/models/address-doctor/address-doctor-event.model.ts @@ -0,0 +1,5 @@ +export enum AddressDoctorEvents { + CheckAddress = 'check-address', + CheckAddressSuccess = 'check-address-successful', + CheckAddressCancelled = 'check-address-cancellation', +} diff --git a/src/app/extensions/address-doctor/models/address-doctor/address-doctor.helper.ts b/src/app/extensions/address-doctor/models/address-doctor/address-doctor.helper.ts new file mode 100644 index 0000000000..f8842ceb54 --- /dev/null +++ b/src/app/extensions/address-doctor/models/address-doctor/address-doctor.helper.ts @@ -0,0 +1,16 @@ +import { isEqual, pick } from 'lodash-es'; + +import { Address } from 'ish-core/models/address/address.model'; + +import { AddressDoctorMapper } from './address-doctor.mapper'; + +export class AddressDoctorHelper { + static equalityCheck(address1: Address, address2: Address): boolean { + if (!address1 || !address2) { + return false; + } + + const attributes = AddressDoctorMapper.attributes; + return isEqual(pick(address1, ...attributes), pick(address2, ...attributes)); + } +} diff --git a/src/app/extensions/address-doctor/models/address-doctor/address-doctor.interface.ts b/src/app/extensions/address-doctor/models/address-doctor/address-doctor.interface.ts new file mode 100644 index 0000000000..e777997f7f --- /dev/null +++ b/src/app/extensions/address-doctor/models/address-doctor/address-doctor.interface.ts @@ -0,0 +1,68 @@ +export interface AddressDoctorVariants { + Variants: AddressDoctorVariant[]; +} + +export interface AddressDoctorVariant { + StatusValues: StatusValues; + AddressElements: AddressElements; + PreformattedData: PreformattedData; +} + +interface StatusValues { + AddressType: string; + ResultGroup: string; + LanguageISO3: string; + UsedVerificationLevel: string; + MatchPercentage: string; + Script: string; + AddressCount: string; + ResultQuality: number; +} + +interface AddressElements { + Street: AddressElement[]; + HouseNumber: AddressElement[]; + Locality: AddressElement[]; + PostalCode: AddressElement[]; + AdministrativeDivision: AdministrativeDivision[]; + Country: Country[]; +} + +interface AddressElement { + Value: string; + SubItems: SubItems; +} + +interface SubItems { + Name?: string; + // eslint-disable-next-line id-blacklist + Number?: string; + Base?: string; +} + +interface AdministrativeDivision { + Value: string; + Variants: Variants; +} + +interface Variants { + Extended: string; + ISO: string; + Abbreviation: string; +} + +interface Country { + Code: string; + Name: string; +} + +interface PreformattedData { + SingleAddressLine: PreformattedDataValue; + PostalDeliveryAddressLines: PreformattedDataValue[]; + PostalFormattedAddressLines: PreformattedDataValue[]; + PostalLocalityLine: PreformattedDataValue; +} + +interface PreformattedDataValue { + Value: string; +} diff --git a/src/app/extensions/address-doctor/models/address-doctor/address-doctor.mapper.ts b/src/app/extensions/address-doctor/models/address-doctor/address-doctor.mapper.ts new file mode 100644 index 0000000000..c3e7680310 --- /dev/null +++ b/src/app/extensions/address-doctor/models/address-doctor/address-doctor.mapper.ts @@ -0,0 +1,23 @@ +import { Address } from 'ish-core/models/address/address.model'; + +import { AddressDoctorVariant } from './address-doctor.interface'; + +/** + * Map incoming data from Address Doctor REST API to ICM compliant addresses. + * The mapper is implemented and tested against German and British addresses. + * The implementation with attributes needs to be adapted for other foreign addresses, when the overwrite does not match. + * + */ +export class AddressDoctorMapper { + static attributes = ['addressLine1', 'postalCode', 'city']; + + static fromData(variant: AddressDoctorVariant): Partial
{ + return { + addressLine1: `${variant.AddressElements.Street ? variant.AddressElements.Street[0].Value : ''} ${ + variant.AddressElements.HouseNumber ? variant.AddressElements.HouseNumber[0].Value : '' + }`.trim(), + postalCode: (variant.AddressElements.PostalCode ? variant.AddressElements.PostalCode[0].Value : '').trim(), + city: variant.AddressElements.Locality.map(loc => loc.Value).join(' '), + }; + } +} diff --git a/src/app/extensions/address-doctor/services/address-doctor-events/address-doctor-events.service.ts b/src/app/extensions/address-doctor/services/address-doctor-events/address-doctor-events.service.ts new file mode 100644 index 0000000000..5152d61134 --- /dev/null +++ b/src/app/extensions/address-doctor/services/address-doctor-events/address-doctor-events.service.ts @@ -0,0 +1,38 @@ +import { inject } from '@angular/core'; +import { filter, take, takeUntil } from 'rxjs/operators'; + +import { FeatureEventResultListener, FeatureEventService } from 'ish-core/utils/feature-event/feature-event.service'; +import { whenTruthy } from 'ish-core/utils/operators'; + +import { AddressDoctorEvents } from '../../models/address-doctor/address-doctor-event.model'; + +export class AddressDoctorEventsService { + static checkAddressResultListenerFactory(): FeatureEventResultListener { + const featureEventService = inject(FeatureEventService); + return { + feature: 'addressDoctor', + event: AddressDoctorEvents.CheckAddress, + resultListener$: (id: string) => { + if (!id) { + return; + } + + return featureEventService.eventResults$.pipe( + whenTruthy(), + // respond only when CheckAddressSuccess event is emitted for specific notification id + filter( + result => result.id === id && result.event === AddressDoctorEvents.CheckAddressSuccess && result.successful + ), + take(1), + takeUntil( + featureEventService.eventResults$.pipe( + whenTruthy(), + // close event stream when CheckAddressCancelled event is emitted for specific notification id + filter(result => result.id === id && result.event === AddressDoctorEvents.CheckAddressCancelled) + ) + ) + ); + }, + }; + } +} diff --git a/src/app/extensions/address-doctor/services/address-doctor/address-doctor.service.spec.ts b/src/app/extensions/address-doctor/services/address-doctor/address-doctor.service.spec.ts new file mode 100644 index 0000000000..1fd9b4d924 --- /dev/null +++ b/src/app/extensions/address-doctor/services/address-doctor/address-doctor.service.spec.ts @@ -0,0 +1,248 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { pick } from 'lodash-es'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { Address } from 'ish-core/models/address/address.model'; +import { StatePropertiesService } from 'ish-core/utils/state-transfer/state-properties.service'; + +import { AddressDoctorConfig } from '../../models/address-doctor/address-doctor-config.model'; + +import { AddressDoctorService } from './address-doctor.service'; + +const mockAddresses = [ + { + id: '0001"', + urn: 'urn:address:customer:JgEKAE8BA50AAAFgDtAd1LZU:1001', + title: 'Ms.', + firstName: 'Patricia', + lastName: 'Miller', + addressLine1: 'Potsdamer Str. 20', + postalCode: '14483', + city: 'Berlin', + }, + { + id: '0002"', + urn: 'urn:address:customer:JgEKAE8BA50AAAFgDtAd1LZU:1002', + title: 'Ms.', + firstName: 'Patricia', + lastName: 'Miller', + addressLine1: 'Berliner Str. 20', + postalCode: '14482', + city: 'Berlin', + }, + { + id: '0003"', + urn: 'urn:address:customer:JgEKAE8BA50AAAFgDtAd1LZU:1003', + title: 'Ms.', + firstName: 'Patricia', + lastName: 'Miller', + addressLine1: 'Neue Promenade 5', + postalCode: '10178', + city: 'Berlin', + companyName1: 'Intershop Communications AG', + }, + { + id: '0004"', + urn: 'urn:address:customer:JgEKAE8BA50AAAFgDtAd1LZU:1004', + title: 'Ms.', + firstName: 'Patricia', + lastName: 'Miller', + addressLine1: 'Intershop Tower', + postalCode: '07743', + city: 'Jena', + companyName1: 'Intershop Communications AG', + }, +] as Address[]; + +const response = { + Status: 'Ok', + StatusDescription: 'No error', + JobToken: '2716bb74-490b-4ab5-9fb0-91a838d84d45', + TransactionInfo: { + TotalTransactionsCharged: 1, + TransactionPools: [{ Type: 'INTERACTIVE', TransactionsCharged: 1 }], + }, + Response: [ + { + ResultInfo: { + ProcessStatus: 'F', + ProcessModeUsed: 'QuickCapture', + ResultCount: 5, + ResultCountOverflow: true, + ResultCountries: ['DEU'], + }, + Results: [ + { + Variants: [ + { + StatusValues: { + AddressType: 'S', + ResultGroup: 'Street', + LanguageISO3: 'DEU', + UsedVerificationLevel: 'None', + MatchPercentage: '79.88', + Script: 'Latin1', + AddressCount: '1', + ResultQuality: 5, + }, + AddressElements: { + Street: [{ Value: 'Theo-Burauen-Platz', SubItems: { Name: 'Theo-Burauen-Platz' } }], + HouseNumber: [{ Value: '65432', SubItems: { number: '65432' } }], + Locality: [ + { Value: 'Köln', SubItems: { Name: 'Köln' } }, + { Value: 'Altstadt-Nord', SubItems: { Name: 'Altstadt-Nord' } }, + ], + PostalCode: [{ Value: '50667', SubItems: { Base: '50667' } }], + AdministrativeDivision: [ + { + Value: 'Nordrhein-Westfalen', + Variants: { Extended: 'Nordrhein-Westfalen', ISO: 'NW', Abbreviation: 'NW' }, + }, + ], + Residue: [{ Value: 'TRAINERSTRASSE', Type: 'Superfluous' }], + Country: [{ Code: 'DE', Name: 'GERMANY' }], + }, + PreformattedData: { + SingleAddressLine: { Value: 'Theo-Burauen-Platz 65432;50667 Köln' }, + PostalDeliveryAddressLines: [{ Value: 'Theo-Burauen-Platz 65432' }], + PostalFormattedAddressLines: [{ Value: 'Theo-Burauen-Platz 65432' }, { Value: '50667 Köln' }], + PostalLocalityLine: { Value: '50667 Köln' }, + }, + }, + ], + }, + ], + }, + ], +}; + +describe('Address Doctor Service', () => { + let addressDoctorService: AddressDoctorService; + let controller: HttpTestingController; + let statePropertiesService: StatePropertiesService; + + beforeEach(() => { + statePropertiesService = mock(StatePropertiesService); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [{ provide: StatePropertiesService, useFactory: () => instance(statePropertiesService) }], + }); + addressDoctorService = TestBed.inject(AddressDoctorService); + + controller = TestBed.inject(HttpTestingController); + + when( + statePropertiesService.getStateOrEnvOrDefault('ADDRESS_DOCTOR', 'addressDoctor') + ).thenReturn( + of({ + login: 'login', + password: 'password', + maxResultCount: 5, + url: 'http://address-doctor.com', + }) + ); + }); + + afterEach(() => { + controller.verify(); + }); + + it('should be created', () => { + expect(addressDoctorService).toBeTruthy(); + }); + + it('should always call underlying service config/start endpoint with product id', done => { + addressDoctorService.postAddress(mockAddresses[0]).subscribe({ + next: data => { + expect(data).toMatchInlineSnapshot(` +[ + { + "addressLine1": "Theo-Burauen-Platz 65432", + "city": "Köln Altstadt-Nord", + "firstName": "Patricia", + "id": "0001"", + "lastName": "Miller", + "postalCode": "50667", + "title": "Ms.", + "urn": "urn:address:customer:JgEKAE8BA50AAAFgDtAd1LZU:1001", + }, +] +`); + }, + error: fail, + complete: done, + }); + + const req = controller.expectOne(() => true); + + expect(pick(req.request, 'urlWithParams', 'body', 'method')).toMatchInlineSnapshot(` + { + "body": { + "Login": "login", + "Password": "password", + "Request": { + "IO": { + "Inputs": [ + { + "AddressElements": { + "Country": undefined, + }, + "PreformattedData": { + "SingleAddressLine": "Potsdamer Str. 20;14483;Berlin", + }, + }, + ], + }, + "Parameters": { + "CountrySets": [ + { + "OutputDetail": { + "PreformattedData": { + "PostalFormattedAddressLines": true, + "SingleAddressLine": true, + "SingleAddressLineDelimiter": "Semicolon", + }, + "SubItems": true, + }, + "Result": { + "MaxResultCount": 5, + "NumericRangeExpansion": { + "RangeExpansionType": "Flexible", + "RangesToExpand": "None", + }, + }, + "Standardizations": [ + { + "Default": { + "AliasHandling": "PostalAdmin", + "Casing": "PostalAdmin", + "CountryCodeType": "ISO2", + "CountryNameType": "NameEN", + "DescriptorLength": "Database", + "FormatWithCountry": false, + "MaxItemLength": 255, + "PreferredScript": { + "LimitLatinCharacters": "Latin1", + "Script": "Latin", + "TransliterationType": "Default", + }, + }, + }, + ], + }, + ], + "Mode": "QuickCapture", + }, + }, + "UseTransactions": "PRODUCTION", + }, + "method": "POST", + "urlWithParams": "http://address-doctor.com", + } + `); + + req.flush(response); + }); +}); diff --git a/src/app/extensions/address-doctor/services/address-doctor/address-doctor.service.ts b/src/app/extensions/address-doctor/services/address-doctor/address-doctor.service.ts new file mode 100644 index 0000000000..e01d1f930c --- /dev/null +++ b/src/app/extensions/address-doctor/services/address-doctor/address-doctor.service.ts @@ -0,0 +1,118 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, catchError, map, of, switchMap, throwError } from 'rxjs'; + +import { Address } from 'ish-core/models/address/address.model'; +import { whenTruthy } from 'ish-core/utils/operators'; +import { StatePropertiesService } from 'ish-core/utils/state-transfer/state-properties.service'; + +import { AddressDoctorConfig } from '../../models/address-doctor/address-doctor-config.model'; +import { AddressDoctorVariants } from '../../models/address-doctor/address-doctor.interface'; +import { AddressDoctorMapper } from '../../models/address-doctor/address-doctor.mapper'; + +@Injectable({ providedIn: 'root' }) +export class AddressDoctorService { + private http = inject(HttpClient); + private statePropertiesService = inject(StatePropertiesService); + + postAddress(address: Address): Observable { + let addressLine = ''; + + if (address.addressLine2) { + addressLine = `${address.addressLine1};${address.addressLine2};${address.postalCode};${address.city}`; + } else { + addressLine = `${address.addressLine1};${address.postalCode};${address.city}`; + } + + return this.mapToBody(address, addressLine).pipe( + whenTruthy(), + switchMap(({ url, body }) => + this.http.post(url, body).pipe( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + map((body: any) => { + if (body?.Status !== 'Ok') { + return throwError(() => body?.StatusDescription); + } + return body.Response[0].Results; + }), + map(results => results.map(result => ({ ...address, ...AddressDoctorMapper.fromData(result.Variants[0]) }))), + // should return empty suggestions in case an error occurs + catchError(() => of([])) + ) + ) + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapToBody(address: Address, addressLine: string): Observable<{ url: string; body: any }> { + return this.statePropertiesService + .getStateOrEnvOrDefault('ADDRESS_DOCTOR', 'addressDoctor') + .pipe( + whenTruthy(), + map(config => ({ + url: config.url, + body: { + Login: config.login, + Password: config.password, + UseTransactions: 'PRODUCTION', + Request: { + Parameters: { + Mode: 'QuickCapture', + + CountrySets: [ + { + OutputDetail: { + PreformattedData: { + PostalFormattedAddressLines: true, + SingleAddressLine: true, + SingleAddressLineDelimiter: 'Semicolon', + }, + SubItems: true, + }, + Result: { + MaxResultCount: config.maxResultCount, + NumericRangeExpansion: { + RangesToExpand: 'None', + RangeExpansionType: 'Flexible', + }, + }, + Standardizations: [ + { + Default: { + PreferredScript: { + Script: 'Latin', + TransliterationType: 'Default', + LimitLatinCharacters: 'Latin1', + }, + FormatWithCountry: false, + CountryNameType: 'NameEN', + CountryCodeType: 'ISO2', + MaxItemLength: 255, + Casing: 'PostalAdmin', + DescriptorLength: 'Database', + AliasHandling: 'PostalAdmin', + }, + }, + ], + }, + ], + }, + + IO: { + Inputs: [ + { + AddressElements: { + Country: address.countryCode, + }, + PreformattedData: { + SingleAddressLine: addressLine, + }, + }, + ], + }, + }, + }, + })) + ); + } +} diff --git a/src/app/extensions/address-doctor/shared/address-doctor-modal/address-doctor-modal.component.html b/src/app/extensions/address-doctor/shared/address-doctor-modal/address-doctor-modal.component.html new file mode 100644 index 0000000000..7d77ca450f --- /dev/null +++ b/src/app/extensions/address-doctor/shared/address-doctor-modal/address-doctor-modal.component.html @@ -0,0 +1,24 @@ + + + + + + + diff --git a/src/app/extensions/address-doctor/shared/address-doctor-modal/address-doctor-modal.component.spec.ts b/src/app/extensions/address-doctor/shared/address-doctor-modal/address-doctor-modal.component.spec.ts new file mode 100644 index 0000000000..91af752be7 --- /dev/null +++ b/src/app/extensions/address-doctor/shared/address-doctor-modal/address-doctor-modal.component.spec.ts @@ -0,0 +1,125 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { pick } from 'lodash-es'; + +import { Address } from 'ish-core/models/address/address.model'; +import { FormlyTestingModule } from 'ish-shared/formly/dev/testing/formly-testing.module'; + +import { AddressDoctorModalComponent } from './address-doctor-modal.component'; + +const mockAddresses = [ + { + id: '0001"', + urn: 'urn:address:customer:JgEKAE8BA50AAAFgDtAd1LZU:1001', + title: 'Ms.', + firstName: 'Patricia', + lastName: 'Miller', + addressLine1: 'Potsdamer Str. 20', + postalCode: '14483', + city: 'Berlin', + }, + { + id: '0002"', + urn: 'urn:address:customer:JgEKAE8BA50AAAFgDtAd1LZU:1002', + title: 'Ms.', + firstName: 'Patricia', + lastName: 'Miller', + addressLine1: 'Berliner Str. 20', + postalCode: '14482', + city: 'Berlin', + }, + { + id: '0003"', + urn: 'urn:address:customer:JgEKAE8BA50AAAFgDtAd1LZU:1003', + title: 'Ms.', + firstName: 'Patricia', + lastName: 'Miller', + addressLine1: 'Neue Promenade 5', + postalCode: '10178', + city: 'Berlin', + companyName1: 'Intershop Communications AG', + }, + { + id: '0004"', + urn: 'urn:address:customer:JgEKAE8BA50AAAFgDtAd1LZU:1004', + title: 'Ms.', + firstName: 'Patricia', + lastName: 'Miller', + addressLine1: 'Intershop Tower', + postalCode: '07743', + city: 'Jena', + companyName1: 'Intershop Communications AG', + }, +] as Address[]; + +describe('Address Doctor Modal Component', () => { + let component: AddressDoctorModalComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FormlyTestingModule, TranslateModule.forRoot()], + declarations: [AddressDoctorModalComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AddressDoctorModalComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display modal dialog when open function is called', () => { + fixture.detectChanges(); + component.openModal(mockAddresses[0], mockAddresses); + expect(component.ngbModalRef).toBeTruthy(); + + const mapped = component.fields.map(field => pick(field, ['type', 'key'])); + expect(mapped).toMatchInlineSnapshot( + ` + [ + { + "key": "defaultText", + "type": "ish-html-text-field", + }, + { + "key": "address", + "type": "ish-radio-field", + }, + { + "key": "suggestionText", + "type": "ish-html-text-field", + }, + { + "key": "address", + "type": "ish-radio-field", + }, + { + "key": "address", + "type": "ish-radio-field", + }, + { + "key": "address", + "type": "ish-radio-field", + }, + { + "key": "address", + "type": "ish-radio-field", + }, + ] + ` + ); + }); + + it('should not display modal dialog when open function is not called', () => { + fixture.detectChanges(); + expect(component.ngbModalRef).toBeFalsy(); + }); +}); diff --git a/src/app/extensions/address-doctor/shared/address-doctor-modal/address-doctor-modal.component.ts b/src/app/extensions/address-doctor/shared/address-doctor-modal/address-doctor-modal.component.ts new file mode 100644 index 0000000000..61ce092d1f --- /dev/null +++ b/src/app/extensions/address-doctor/shared/address-doctor-modal/address-doctor-modal.component.ts @@ -0,0 +1,121 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnDestroy, + Output, + TemplateRef, + ViewChild, + inject, +} from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { FormlyFieldConfig } from '@ngx-formly/core'; +import { TranslateService } from '@ngx-translate/core'; +import { Subject, takeUntil } from 'rxjs'; + +import { Address } from 'ish-core/models/address/address.model'; +import { ModalOptions } from 'ish-shared/components/common/modal-dialog/modal-dialog.component'; + +@Component({ + selector: 'ish-address-doctor-modal', + templateUrl: './address-doctor-modal.component.html', + changeDetection: ChangeDetectionStrategy.Default, +}) +export class AddressDoctorModalComponent implements OnDestroy { + @Input() options: ModalOptions; + @Output() confirmAddress = new EventEmitter
(); + @Output() hidden = new EventEmitter(); + @ViewChild('template', { static: true }) modalDialogTemplate: TemplateRef; + + private ngbModal = inject(NgbModal); + private translateService = inject(TranslateService); + + ngbModalRef: NgbModalRef; + + form: FormGroup = new FormGroup({}); + fields: FormlyFieldConfig[]; + model: { + defaultText: string; + suggestionText: string; + address: Address; + }; + + private destroy$ = new Subject(); + + openModal(address: Address, suggestions: Address[]) { + this.fields = this.getFields(address, suggestions); + this.model = { + defaultText: ` + ${this.translateService.instant('address.doctor.suggestion.text')} +

${this.translateService.instant('address.doctor.suggestion.address')}

`, + suggestionText: `

${this.translateService.instant('address.doctor.suggestion.proposals')}

`, + address, + }; + + this.ngbModalRef = this.ngbModal.open(this.modalDialogTemplate, this.options || { size: 'lg' }); + this.ngbModalRef.hidden.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.hidden.emit(true); + }); + } + + hide() { + this.ngbModalRef.close(); + } + + confirm() { + this.ngbModalRef.close(); + this.confirmAddress.emit(this.model.address); + } + + getFields(address: Address, suggestions: Address[]): FormlyFieldConfig[] { + return [ + { + type: 'ish-html-text-field', + key: 'defaultText', + wrappers: [], + props: { + fieldClass: 'col-12', + }, + }, + { + type: 'ish-radio-field', + key: 'address', + props: { + fieldClass: 'col-12', + id: address.id, + value: address, + label: this.formatAddress(address), + }, + }, + { + type: 'ish-html-text-field', + key: 'suggestionText', + wrappers: [], + props: { + fieldClass: 'col-12', + }, + }, + ...suggestions.map(suggestion => ({ + type: 'ish-radio-field', + key: 'address', + props: { + fieldClass: 'col-12', + id: suggestion.id, + value: suggestion, + label: this.formatAddress(suggestion), + }, + })), + ]; + } + + private formatAddress(address: Address): string { + return `${address.addressLine1}, ${address.postalCode}, ${address.city}`; + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/app/extensions/address-doctor/shared/address-doctor/address-doctor.component.html b/src/app/extensions/address-doctor/shared/address-doctor/address-doctor.component.html new file mode 100644 index 0000000000..32fa63e532 --- /dev/null +++ b/src/app/extensions/address-doctor/shared/address-doctor/address-doctor.component.html @@ -0,0 +1,6 @@ + diff --git a/src/app/extensions/address-doctor/shared/address-doctor/address-doctor.component.spec.ts b/src/app/extensions/address-doctor/shared/address-doctor/address-doctor.component.spec.ts new file mode 100644 index 0000000000..4ebc86e007 --- /dev/null +++ b/src/app/extensions/address-doctor/shared/address-doctor/address-doctor.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { Address } from 'ish-core/models/address/address.model'; +import { FeatureEventNotifier, FeatureEventService } from 'ish-core/utils/feature-event/feature-event.service'; + +import { AddressDoctorFacade } from '../../facades/address-doctor.facade'; + +import { AddressDoctorComponent } from './address-doctor.component'; + +const mockAddress = { + id: '0001"', + urn: 'urn:address:customer:JgEKAE8BA50AAAFgDtAd1LZU:1001', + title: 'Ms.', + firstName: 'Patricia', + lastName: 'Miller', + addressLine1: 'Potsdamer Str. 20', + postalCode: '14483', + city: 'Berlin', +} as Address; + +describe('Address Doctor Component', () => { + let component: AddressDoctorComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + let featureEventService: FeatureEventService; + + beforeEach(async () => { + featureEventService = mock(FeatureEventService); + + await TestBed.configureTestingModule({ + providers: [ + { provide: AddressDoctorFacade, useFactory: () => instance(mock(AddressDoctorFacade)) }, + { provide: FeatureEventService, useFactory: () => instance(featureEventService) }, + ], + }).compileComponents(); + + when(featureEventService.eventNotifier$).thenReturn( + of({ + id: 'custom-process-id', + feature: 'addressDoctor', + event: 'check-address', + data: mockAddress, + } as FeatureEventNotifier) + ); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AddressDoctorComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/extensions/address-doctor/shared/address-doctor/address-doctor.component.ts b/src/app/extensions/address-doctor/shared/address-doctor/address-doctor.component.ts new file mode 100644 index 0000000000..2f76787b4d --- /dev/null +++ b/src/app/extensions/address-doctor/shared/address-doctor/address-doctor.component.ts @@ -0,0 +1,100 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, Input, OnDestroy, ViewChild, inject } from '@angular/core'; +import { Subject, filter, map, takeUntil, tap } from 'rxjs'; +import { concatMap, first } from 'rxjs/operators'; + +import { Address } from 'ish-core/models/address/address.model'; +import { FeatureEventService } from 'ish-core/utils/feature-event/feature-event.service'; +import { GenerateLazyComponent } from 'ish-core/utils/module-loader/generate-lazy-component.decorator'; +import { whenPropertyHasValue } from 'ish-core/utils/operators'; +import { ModalOptions } from 'ish-shared/components/common/modal-dialog/modal-dialog.component'; + +import { AddressDoctorFacade } from '../../facades/address-doctor.facade'; +import { AddressDoctorEvents } from '../../models/address-doctor/address-doctor-event.model'; +import { AddressDoctorHelper } from '../../models/address-doctor/address-doctor.helper'; +import { AddressDoctorModalComponent } from '../address-doctor-modal/address-doctor-modal.component'; + +@Component({ + selector: 'ish-address-doctor', + templateUrl: './address-doctor.component.html', + changeDetection: ChangeDetectionStrategy.Default, +}) +@GenerateLazyComponent() +export class AddressDoctorComponent implements OnDestroy, AfterViewInit { + @Input() options: ModalOptions; + // related address doctor modal + @ViewChild('modal') modal: AddressDoctorModalComponent; + + private featureEventService = inject(FeatureEventService); + private addressDoctorFacade = inject(AddressDoctorFacade); + + private eventId: string; + private destroy$ = new Subject(); + + ngAfterViewInit(): void { + // react on all CheckAddress event notifier for 'addressDoctor' feature + this.featureEventService.eventNotifier$ + .pipe( + filter(({ event }) => event === AddressDoctorEvents.CheckAddress), + whenPropertyHasValue('feature', 'addressDoctor'), + // save notifier id for event result + tap(({ id }) => (this.eventId = id)), + map(({ data }) => this.mapToAddress(data)), + concatMap(address => + this.addressDoctorFacade.checkAddress(address).pipe( + first(), + map(suggestions => ({ address, suggestions })) + ) + ), + takeUntil(this.destroy$) + ) + // open related address doctor modal with event notifier address data + .subscribe(({ address, suggestions }) => { + if ( + suggestions?.length && + !suggestions.find(suggestion => AddressDoctorHelper.equalityCheck(address, suggestion)) + ) { + this.modal.openModal(address, suggestions); + } else { + this.sendAddress(address); + } + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapToAddress(data: any): Address { + if (this.isCheckAddressOptions(data)) { + const { address } = data; + return address; + } + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private isCheckAddressOptions(object: any): object is { + address: Address; + } { + return 'address' in object; + } + + /** + * Send event result for given address + * @param address address callback + */ + sendAddress(address: Address) { + this.featureEventService.sendResult(this.eventId, AddressDoctorEvents.CheckAddressSuccess, true, address); + } + + /** + * Send event result when modal component was cancelled + */ + onModalHidden(hidden: boolean) { + if (hidden) { + this.featureEventService.sendResult(this.eventId, AddressDoctorEvents.CheckAddressCancelled, true); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/app/pages/account-addresses/account-addresses/account-addresses.component.html b/src/app/pages/account-addresses/account-addresses/account-addresses.component.html index 4db249bb52..5f8fc4589b 100644 --- a/src/app/pages/account-addresses/account-addresses/account-addresses.component.html +++ b/src/app/pages/account-addresses/account-addresses/account-addresses.component.html @@ -197,4 +197,6 @@

{{ 'account.addresses.heading' | translate }}

+ + diff --git a/src/app/pages/account-addresses/account-addresses/account-addresses.component.spec.ts b/src/app/pages/account-addresses/account-addresses/account-addresses.component.spec.ts index 82a6c27e43..41302e8633 100644 --- a/src/app/pages/account-addresses/account-addresses/account-addresses.component.spec.ts +++ b/src/app/pages/account-addresses/account-addresses/account-addresses.component.spec.ts @@ -8,14 +8,18 @@ import { of } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { AccountFacade } from 'ish-core/facades/account.facade'; +import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { Address } from 'ish-core/models/address/address.model'; import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; +import { FeatureEventResult, FeatureEventService } from 'ish-core/utils/feature-event/feature-event.service'; import { AddressComponent } from 'ish-shared/components/address/address/address.component'; import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; import { ModalDialogComponent } from 'ish-shared/components/common/modal-dialog/modal-dialog.component'; import { FormlyCustomerAddressFormComponent } from 'ish-shared/formly-address-forms/components/formly-customer-address-form/formly-customer-address-form.component'; import { FormlyTestingModule } from 'ish-shared/formly/dev/testing/formly-testing.module'; +import { LazyAddressDoctorComponent } from '../../../extensions/address-doctor/exports/lazy-address-doctor/lazy-address-doctor.component'; + import { AccountAddressesComponent } from './account-addresses.component'; const mockAddresses = [ @@ -75,220 +79,298 @@ describe('Account Addresses Component', () => { let element: HTMLElement; let accountFacade: AccountFacade; - beforeEach(async () => { - accountFacade = mock(AccountFacade); - await TestBed.configureTestingModule({ - declarations: [ - AccountAddressesComponent, - MockComponent(AddressComponent), - MockComponent(ErrorMessageComponent), - MockComponent(FaIconComponent), - MockComponent(FormlyCustomerAddressFormComponent), - MockComponent(ModalDialogComponent), - ], - imports: [FormlyTestingModule, RouterTestingModule, TranslateModule.forRoot()], - providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], - }) - .overrideComponent(AccountAddressesComponent, { - set: { changeDetection: ChangeDetectionStrategy.Default }, + describe('default settings', () => { + beforeEach(async () => { + accountFacade = mock(AccountFacade); + await TestBed.configureTestingModule({ + declarations: [ + AccountAddressesComponent, + MockComponent(AddressComponent), + MockComponent(ErrorMessageComponent), + MockComponent(FaIconComponent), + MockComponent(FormlyCustomerAddressFormComponent), + MockComponent(LazyAddressDoctorComponent), + MockComponent(ModalDialogComponent), + ], + imports: [ + FeatureToggleModule.forTesting(), + FormlyTestingModule, + RouterTestingModule, + TranslateModule.forRoot(), + ], + providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(AccountAddressesComponent); - component = fixture.componentInstance; - element = fixture.nativeElement; - - when(accountFacade.addresses$()).thenReturn(of(mockAddresses)); - - when(accountFacade.user$).thenReturn( - of({ - ...patriciaInfo, - preferredInvoiceToAddressUrn: mockAddresses[0].urn, - }) - ); - }); + .overrideComponent(AccountAddressesComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountAddressesComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + when(accountFacade.addresses$()).thenReturn(of(mockAddresses)); + + when(accountFacade.user$).thenReturn( + of({ + ...patriciaInfo, + preferredInvoiceToAddressUrn: mockAddresses[0].urn, + }) + ); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display only one preferred address if preferred invoice and shipping address are equal', () => { + when(accountFacade.user$).thenReturn( + of({ + ...patriciaInfo, + preferredInvoiceToAddressUrn: mockAddresses[0].urn, + preferredShipToAddressUrn: mockAddresses[0].urn, + }) + ); + + fixture.detectChanges(); + + expect(component.preferredAddressesEqual).toBeTruthy(); + expect(element.querySelector('div[data-testing-id=preferred-invoice-and-shipping-address]')).toBeTruthy(); + expect( + element.querySelectorAll( + 'div[data-testing-id=preferred-invoice-and-shipping-address] formly-group formly-field' + ) + ).toHaveLength(2); + expect(element.querySelector('div[data-testing-id=preferred-invoice-address]')).toBeFalsy(); + expect(element.querySelector('div[data-testing-id=preferred-shipping-address]')).toBeFalsy(); + }); + + it('should display both preferred addresses if preferred invoice and shipping address are not equal', () => { + when(accountFacade.user$).thenReturn( + of({ + ...patriciaInfo, + preferredInvoiceToAddressUrn: mockAddresses[0].urn, + preferredShipToAddressUrn: mockAddresses[1].urn, + }) + ); + + fixture.detectChanges(); + + expect(element.querySelector('div[data-testing-id=preferred-invoice-and-shipping-address]')).toBeFalsy(); + expect(element.querySelector('div[data-testing-id=preferred-invoice-address]')).toBeTruthy(); + expect( + element.querySelectorAll('div[data-testing-id=preferred-invoice-address] formly-group formly-field') + ).toHaveLength(1); + expect(element.querySelector('div[data-testing-id=preferred-shipping-address]')).toBeTruthy(); + expect( + element.querySelectorAll('div[data-testing-id=preferred-shipping-address] formly-group formly-field') + ).toHaveLength(1); + }); + + it('should not display further addresses if only preferred invoice and shipping addresses are available', () => { + when(accountFacade.user$).thenReturn( + of({ + ...patriciaInfo, + preferredInvoiceToAddressUrn: mockAddresses[0].urn, + preferredShipToAddressUrn: mockAddresses[1].urn, + }) + ); + when(accountFacade.addresses$()).thenReturn(of([mockAddresses[0], mockAddresses[1]])); + + fixture.detectChanges(); + + expect(element.querySelector('div[data-testing-id=further-addresses]')).toBeFalsy(); + }); + + it('should display the proper headlines and info texts if no preferred addresses are available', () => { + when(accountFacade.user$).thenReturn( + of({ + ...patriciaInfo, + preferredInvoiceToAddressUrn: undefined, + preferredShipToAddressUrn: undefined, + }) + ); + + fixture.detectChanges(); + + expect(element.querySelector('div[data-testing-id=preferred-invoice-and-shipping-address]')).toBeFalsy(); + expect(element.querySelector('div[data-testing-id=preferred-invoice-address] p.no-address-info')).toBeTruthy(); + expect(element.querySelector('div[data-testing-id=preferred-shipping-address] p.no-address-info')).toBeTruthy(); + }); + + it('should not filter further addresses if no preferred addresses are available', () => { + when(accountFacade.user$).thenReturn( + of({ + ...patriciaInfo, + preferredInvoiceToAddressUrn: undefined, + preferredShipToAddressUrn: undefined, + }) + ); + + fixture.detectChanges(); + + expect(element.querySelector('div[data-testing-id=further-addresses]')).toBeTruthy(); + expect(element.querySelectorAll('div[data-testing-id=further-addresses] .list-item-row')).toHaveLength(4); + }); + + it('should reduce further addresses by two if both preferred addresses are available', () => { + when(accountFacade.user$).thenReturn( + of({ + ...patriciaInfo, + preferredInvoiceToAddressUrn: mockAddresses[0].urn, + preferredShipToAddressUrn: mockAddresses[1].urn, + }) + ); + + fixture.detectChanges(); + + expect(element.querySelector('div[data-testing-id=further-addresses]')).toBeTruthy(); + expect(element.querySelectorAll('div[data-testing-id=further-addresses] .list-item-row')).toHaveLength(2); + }); + + it('should reduce further addresses by one if only one preferred address is available', () => { + fixture.detectChanges(); + + expect(element.querySelector('div[data-testing-id=further-addresses]')).toBeTruthy(); + expect(element.querySelectorAll('div[data-testing-id=further-addresses] .list-item-row')).toHaveLength(3); + }); + + it('should not display any address if user has no saved addresses', () => { + when(accountFacade.addresses$()).thenReturn(of([])); + + fixture.detectChanges(); + + expect(element.querySelector('div[data-testing-id=preferred-invoice-and-shipping-address]')).toBeFalsy(); + expect(element.querySelector('div[data-testing-id=preferred-invoice-address]')).toBeFalsy(); + expect(element.querySelector('div[data-testing-id=preferred-shipping-address]')).toBeFalsy(); + expect(element.querySelector('div[data-testing-id=further-addresses]')).toBeFalsy(); + expect(element.querySelector('p.empty-list')).toBeTruthy(); + }); + + it('should not show no addresses info if there are addresses available', () => { + fixture.detectChanges(); + + expect(element.querySelector('p.empty-list')).toBeFalsy(); + }); + + it('should render create address button after creation', () => { + fixture.detectChanges(); + expect(element.querySelector('a[data-testing-id=create-address-button]')).toBeTruthy(); + expect(element.querySelector('[data-testing-id=create-address-form]')).toBeFalsy(); + }); + + it('should render create address form if showCreateAddressForm is called', async () => { + fixture.detectChanges(); + component.showCreateAddressForm(); + fixture.detectChanges(); + + await fixture.whenStable(); - it('should be created', () => { - expect(component).toBeTruthy(); - expect(element).toBeTruthy(); - expect(() => fixture.detectChanges()).not.toThrow(); - }); - - it('should display only one preferred address if preferred invoice and shipping address are equal', () => { - when(accountFacade.user$).thenReturn( - of({ - ...patriciaInfo, - preferredInvoiceToAddressUrn: mockAddresses[0].urn, - preferredShipToAddressUrn: mockAddresses[0].urn, - }) - ); - - fixture.detectChanges(); - - expect(component.preferredAddressesEqual).toBeTruthy(); - expect(element.querySelector('div[data-testing-id=preferred-invoice-and-shipping-address]')).toBeTruthy(); - expect( - element.querySelectorAll('div[data-testing-id=preferred-invoice-and-shipping-address] formly-group formly-field') - ).toHaveLength(2); - expect(element.querySelector('div[data-testing-id=preferred-invoice-address]')).toBeFalsy(); - expect(element.querySelector('div[data-testing-id=preferred-shipping-address]')).toBeFalsy(); - }); + expect(component.isCreateAddressFormCollapsed).toBeFalse(); + expect(element.querySelector('[data-testing-id=create-address-form]')).toBeTruthy(); + expect(element.querySelector('a[data-testing-id=create-address-button]')).toBeFalsy(); + }); - it('should display both preferred addresses if preferred invoice and shipping address are not equal', () => { - when(accountFacade.user$).thenReturn( - of({ - ...patriciaInfo, - preferredInvoiceToAddressUrn: mockAddresses[0].urn, - preferredShipToAddressUrn: mockAddresses[1].urn, - }) - ); - - fixture.detectChanges(); - - expect(element.querySelector('div[data-testing-id=preferred-invoice-and-shipping-address]')).toBeFalsy(); - expect(element.querySelector('div[data-testing-id=preferred-invoice-address]')).toBeTruthy(); - expect( - element.querySelectorAll('div[data-testing-id=preferred-invoice-address] formly-group formly-field') - ).toHaveLength(1); - expect(element.querySelector('div[data-testing-id=preferred-shipping-address]')).toBeTruthy(); - expect( - element.querySelectorAll('div[data-testing-id=preferred-shipping-address] formly-group formly-field') - ).toHaveLength(1); - }); + it('should render an error if an error occurs', () => { + component.error = makeHttpError({ status: 404 }); + fixture.detectChanges(); + expect(element.querySelector('ish-error-message')).toBeTruthy(); + }); - it('should not display further addresses if only preferred invoice and shipping addresses are available', () => { - when(accountFacade.user$).thenReturn( - of({ - ...patriciaInfo, - preferredInvoiceToAddressUrn: mockAddresses[0].urn, - preferredShipToAddressUrn: mockAddresses[1].urn, - }) - ); - when(accountFacade.addresses$()).thenReturn(of([mockAddresses[0], mockAddresses[1]])); + it('should emit createCustomerAddress event when createCustomerAddress is triggered', () => { + const address = { urn: '123' } as Address; - fixture.detectChanges(); + component.createAddress(address); - expect(element.querySelector('div[data-testing-id=further-addresses]')).toBeFalsy(); - }); + verify(accountFacade.createCustomerAddress(anything())).once(); + }); - it('should display the proper headlines and info texts if no preferred addresses are available', () => { - when(accountFacade.user$).thenReturn( - of({ - ...patriciaInfo, - preferredInvoiceToAddressUrn: undefined, - preferredShipToAddressUrn: undefined, - }) - ); + it('should emit updateAddress event when updateAddress is triggered', () => { + const address = { id: '123' } as Address; - fixture.detectChanges(); + component.updateAddress(address); - expect(element.querySelector('div[data-testing-id=preferred-invoice-and-shipping-address]')).toBeFalsy(); - expect(element.querySelector('div[data-testing-id=preferred-invoice-address] p.no-address-info')).toBeTruthy(); - expect(element.querySelector('div[data-testing-id=preferred-shipping-address] p.no-address-info')).toBeTruthy(); - }); + verify(accountFacade.updateCustomerAddress(anything())).once(); + }); - it('should not filter further addresses if no preferred addresses are available', () => { - when(accountFacade.user$).thenReturn( - of({ - ...patriciaInfo, - preferredInvoiceToAddressUrn: undefined, - preferredShipToAddressUrn: undefined, - }) - ); + it('should emit deleteCustomerAddress event when deleteCustomerAddress is triggered', () => { + const address = { id: '123' } as Address; - fixture.detectChanges(); + component.deleteAddress(address); - expect(element.querySelector('div[data-testing-id=further-addresses]')).toBeTruthy(); - expect(element.querySelectorAll('div[data-testing-id=further-addresses] .list-item-row')).toHaveLength(4); + verify(accountFacade.deleteCustomerAddress(anything())).once(); + }); }); - it('should reduce further addresses by two if both preferred addresses are available', () => { - when(accountFacade.user$).thenReturn( - of({ - ...patriciaInfo, - preferredInvoiceToAddressUrn: mockAddresses[0].urn, - preferredShipToAddressUrn: mockAddresses[1].urn, + describe('enabled address doctor feature', () => { + let featureEventService: FeatureEventService; + beforeEach(async () => { + accountFacade = mock(AccountFacade); + featureEventService = mock(FeatureEventService); + await TestBed.configureTestingModule({ + declarations: [ + AccountAddressesComponent, + MockComponent(ErrorMessageComponent), + MockComponent(LazyAddressDoctorComponent), + ], + imports: [FeatureToggleModule.forTesting('addressDoctor'), TranslateModule.forRoot()], + providers: [ + { provide: AccountFacade, useFactory: () => instance(accountFacade) }, + { provide: FeatureEventService, useFactory: () => instance(featureEventService) }, + ], }) - ); - - fixture.detectChanges(); - - expect(element.querySelector('div[data-testing-id=further-addresses]')).toBeTruthy(); - expect(element.querySelectorAll('div[data-testing-id=further-addresses] .list-item-row')).toHaveLength(2); - }); - - it('should reduce further addresses by one if only one preferred address is available', () => { - fixture.detectChanges(); - - expect(element.querySelector('div[data-testing-id=further-addresses]')).toBeTruthy(); - expect(element.querySelectorAll('div[data-testing-id=further-addresses] .list-item-row')).toHaveLength(3); - }); - - it('should not display any address if user has no saved addresses', () => { - when(accountFacade.addresses$()).thenReturn(of([])); - - fixture.detectChanges(); - - expect(element.querySelector('div[data-testing-id=preferred-invoice-and-shipping-address]')).toBeFalsy(); - expect(element.querySelector('div[data-testing-id=preferred-invoice-address]')).toBeFalsy(); - expect(element.querySelector('div[data-testing-id=preferred-shipping-address]')).toBeFalsy(); - expect(element.querySelector('div[data-testing-id=further-addresses]')).toBeFalsy(); - expect(element.querySelector('p.empty-list')).toBeTruthy(); - }); - - it('should not show no addresses info if there are addresses available', () => { - fixture.detectChanges(); - - expect(element.querySelector('p.empty-list')).toBeFalsy(); - }); - - it('should render create address button after creation', () => { - fixture.detectChanges(); - expect(element.querySelector('a[data-testing-id=create-address-button]')).toBeTruthy(); - expect(element.querySelector('[data-testing-id=create-address-form]')).toBeFalsy(); - }); - - it('should render create address form if showCreateAddressForm is called', async () => { - fixture.detectChanges(); - component.showCreateAddressForm(); - fixture.detectChanges(); - - await fixture.whenStable(); - - expect(component.isCreateAddressFormCollapsed).toBeFalse(); - expect(element.querySelector('[data-testing-id=create-address-form]')).toBeTruthy(); - expect(element.querySelector('a[data-testing-id=create-address-button]')).toBeFalsy(); - }); - - it('should render an error if an error occurs', () => { - component.error = makeHttpError({ status: 404 }); - fixture.detectChanges(); - expect(element.querySelector('ish-error-message')).toBeTruthy(); - }); - - it('should emit createCustomerAddress event when createCustomerAddress is triggered', () => { - const address = { urn: '123' } as Address; - - component.createAddress(address); - - verify(accountFacade.createCustomerAddress(anything())).once(); - }); - - it('should emit updateAddress event when updateAddress is triggered', () => { - const address = { id: '123' } as Address; - - component.updateAddress(address); - - verify(accountFacade.updateCustomerAddress(anything())).once(); - }); - - it('should emit deleteCustomerAddress event when deleteCustomerAddress is triggered', () => { - const address = { id: '123' } as Address; - - component.deleteAddress(address); - - verify(accountFacade.deleteCustomerAddress(anything())).once(); + .overrideComponent(AccountAddressesComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountAddressesComponent); + component = fixture.componentInstance; + + when(accountFacade.addresses$()).thenReturn(of(mockAddresses)); + + when(accountFacade.user$).thenReturn( + of({ + ...patriciaInfo, + preferredInvoiceToAddressUrn: mockAddresses[0].urn, + }) + ); + + when(featureEventService.sendNotification('addressDoctor', 'check-address', anything())).thenReturn( + 'custom-process-id' + ); + when(featureEventService.eventResultListener$('addressDoctor', 'check-address', 'custom-process-id')).thenReturn( + of({ + id: 'custom-process-id', + event: 'check-address-successful', + successful: true, + data: mockAddresses[0], + } as FeatureEventResult) + ); + }); + + it('should emit createCustomerAddress event when createCustomerAddress is triggered', () => { + const address = { urn: '123' } as Address; + + component.createAddress(address); + + verify(featureEventService.sendNotification('addressDoctor', 'check-address', anything())).once(); + verify(accountFacade.createCustomerAddress(anything())).once(); + }); + + it('should emit updateAddress event when updateAddress is triggered', () => { + const address = { id: '123' } as Address; + + component.updateAddress(address); + + verify(featureEventService.sendNotification('addressDoctor', 'check-address', anything())).once(); + verify(accountFacade.updateCustomerAddress(anything())).once(); + }); }); }); diff --git a/src/app/pages/account-addresses/account-addresses/account-addresses.component.ts b/src/app/pages/account-addresses/account-addresses/account-addresses.component.ts index 12c56fff9a..124a10e5f7 100644 --- a/src/app/pages/account-addresses/account-addresses/account-addresses.component.ts +++ b/src/app/pages/account-addresses/account-addresses/account-addresses.component.ts @@ -2,13 +2,15 @@ import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit } from '@a import { FormGroup } from '@angular/forms'; import { FormlyFieldConfig } from '@ngx-formly/core'; import { Observable, Subject, combineLatest } from 'rxjs'; -import { distinctUntilChanged, filter, map, shareReplay, takeUntil, withLatestFrom } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, shareReplay, take, takeUntil, withLatestFrom } from 'rxjs/operators'; import { AccountFacade } from 'ish-core/facades/account.facade'; +import { FeatureToggleService } from 'ish-core/feature-toggle.module'; import { AddressHelper } from 'ish-core/models/address/address.helper'; import { Address } from 'ish-core/models/address/address.model'; import { HttpError } from 'ish-core/models/http-error/http-error.model'; import { User } from 'ish-core/models/user/user.model'; +import { FeatureEventService } from 'ish-core/utils/feature-event/feature-event.service'; import { whenTruthy } from 'ish-core/utils/operators'; import { mapToAddressOptions } from 'ish-shared/forms/utils/forms.service'; @@ -26,7 +28,6 @@ export class AccountAddressesComponent implements OnInit, OnDestroy { addresses$: Observable; user$: Observable; - hasPreferredAddresses = false; preferredAddressesEqual: boolean; preferredInvoiceToAddress: Address; @@ -43,7 +44,11 @@ export class AccountAddressesComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); - constructor(private accountFacade: AccountFacade) {} + constructor( + private accountFacade: AccountFacade, + private featureToggleService: FeatureToggleService, + private featureEventService: FeatureEventService + ) {} ngOnInit() { this.addresses$ = this.accountFacade.addresses$().pipe(shareReplay(1)); @@ -167,11 +172,41 @@ export class AccountAddressesComponent implements OnInit, OnDestroy { } createAddress(address: Address) { - this.accountFacade.createCustomerAddress(address); + if (this.featureToggleService.enabled('addressDoctor')) { + const id = this.featureEventService.sendNotification('addressDoctor', 'check-address', { + address, + }); + + this.featureEventService + .eventResultListener$('addressDoctor', 'check-address', id) + .pipe(whenTruthy(), take(1), takeUntil(this.destroy$)) + .subscribe(({ data }) => { + if (data) { + this.accountFacade.createCustomerAddress(data); + } + }); + } else { + this.accountFacade.createCustomerAddress(address); + } } updateAddress(address: Address): void { - this.accountFacade.updateCustomerAddress(address); + if (this.featureToggleService.enabled('addressDoctor')) { + const id = this.featureEventService.sendNotification('addressDoctor', 'check-address', { + address, + }); + + this.featureEventService + .eventResultListener$('addressDoctor', 'check-address', id) + .pipe(whenTruthy(), take(1), takeUntil(this.destroy$)) + .subscribe(({ data }) => { + if (data) { + this.accountFacade.updateCustomerAddress(data); + } + }); + } else { + this.accountFacade.updateCustomerAddress(address); + } this.hideUpdateAddressForm(); } diff --git a/src/app/pages/registration/registration-page.component.html b/src/app/pages/registration/registration-page.component.html index 5d7a6559a6..6d91adcaa8 100644 --- a/src/app/pages/registration/registration-page.component.html +++ b/src/app/pages/registration/registration-page.component.html @@ -26,4 +26,6 @@ + + diff --git a/src/app/pages/registration/registration-page.component.spec.ts b/src/app/pages/registration/registration-page.component.spec.ts index 78c8dc161d..0de6910641 100644 --- a/src/app/pages/registration/registration-page.component.spec.ts +++ b/src/app/pages/registration/registration-page.component.spec.ts @@ -7,9 +7,12 @@ import { of } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { AccountFacade } from 'ish-core/facades/account.facade'; +import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; import { FormlyTestingModule } from 'ish-shared/formly/dev/testing/formly-testing.module'; +import { LazyAddressDoctorComponent } from '../../extensions/address-doctor/exports/lazy-address-doctor/lazy-address-doctor.component'; + import { RegistrationPageComponent } from './registration-page.component'; import { RegistrationFormConfigurationService } from './services/registration-form-configuration/registration-form-configuration.service'; @@ -26,8 +29,12 @@ describe('Registration Page Component', () => { configService = mock(RegistrationFormConfigurationService); activatedRoute = mock(ActivatedRoute); await TestBed.configureTestingModule({ - declarations: [MockComponent(ErrorMessageComponent), RegistrationPageComponent], - imports: [FormlyTestingModule, TranslateModule.forRoot()], + declarations: [ + MockComponent(ErrorMessageComponent), + MockComponent(LazyAddressDoctorComponent), + RegistrationPageComponent, + ], + imports: [FeatureToggleModule.forTesting('addressDoctor'), FormlyTestingModule, TranslateModule.forRoot()], providers: [ { provide: AccountFacade, useFactory: () => instance(accountFacade) }, { provide: ActivatedRoute, useFactory: () => instance(activatedRoute) }, diff --git a/src/app/pages/registration/registration-page.component.ts b/src/app/pages/registration/registration-page.component.ts index d6423208d6..6cc43bac1f 100644 --- a/src/app/pages/registration/registration-page.component.ts +++ b/src/app/pages/registration/registration-page.component.ts @@ -1,12 +1,15 @@ /* eslint-disable ish-custom-rules/no-intelligence-in-artifacts */ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { UntypedFormGroup } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { AbstractControl, UntypedFormGroup } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { FormlyFieldConfig, FormlyFormOptions } from '@ngx-formly/core'; -import { Observable, tap } from 'rxjs'; +import { Observable, Subject, take, takeUntil, tap } from 'rxjs'; import { AccountFacade } from 'ish-core/facades/account.facade'; +import { FeatureToggleService } from 'ish-core/feature-toggle.module'; +import { Address } from 'ish-core/models/address/address.model'; import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { FeatureEventService } from 'ish-core/utils/feature-event/feature-event.service'; import { whenTruthy } from 'ish-core/utils/operators'; import { markAsDirtyRecursive } from 'ish-shared/forms/utils/form-utils'; @@ -23,13 +26,15 @@ import { templateUrl: './registration-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RegistrationPageComponent implements OnInit { +export class RegistrationPageComponent implements OnInit, OnDestroy { error$: Observable; constructor( private route: ActivatedRoute, private registrationFormConfiguration: RegistrationFormConfigurationService, - private accountFacade: AccountFacade + private accountFacade: AccountFacade, + private featureToggleService: FeatureToggleService, + private featureEventService: FeatureEventService ) {} submitted = false; @@ -42,6 +47,8 @@ export class RegistrationPageComponent implements OnInit { options: FormlyFormOptions; form = new UntypedFormGroup({}); + private destroy$ = new Subject(); + ngOnInit() { this.error$ = this.registrationFormConfiguration.getErrorSources().pipe( whenTruthy(), @@ -67,6 +74,32 @@ export class RegistrationPageComponent implements OnInit { return; } // keep-localization-pattern: ^customer\..*\.error$ + if (this.featureToggleService.enabled('addressDoctor')) { + const id = this.featureEventService.sendNotification('addressDoctor', 'check-address', { + address: this.form.get('address').value, + }); + + this.featureEventService + .eventResultListener$('addressDoctor', 'check-address', id) + .pipe(whenTruthy(), take(1), takeUntil(this.destroy$)) + .subscribe(({ data }) => { + if (data) { + this.onCreateWithSuggestion(data); + } + }); + } else { + this.submitRegistrationForm(); + } + } + + onCreateWithSuggestion(address: Address) { + Object.keys(this.form.get('address').value).forEach(key => + (this.form.get('address').get(key) as AbstractControl).setValue(address[key as keyof Address]) + ); + this.submitRegistrationForm(); + } + + submitRegistrationForm() { this.registrationFormConfiguration.submitRegistrationForm(this.form, this.registrationConfig, this.model); } @@ -78,4 +111,9 @@ export class RegistrationPageComponent implements OnInit { private clearCaptchaToken() { this.form.get('captcha')?.setValue(undefined); } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/src/app/shared/components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component.html b/src/app/shared/components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component.html index f547666eb5..06d2e0df10 100644 --- a/src/app/shared/components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component.html +++ b/src/app/shared/components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component.html @@ -52,3 +52,7 @@

{{ 'checkout.address.billing.label' | translate }}

> + + + + diff --git a/src/app/shared/components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component.spec.ts b/src/app/shared/components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component.spec.ts index 9187b9dcce..86252a11ae 100644 --- a/src/app/shared/components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component.spec.ts +++ b/src/app/shared/components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component.spec.ts @@ -10,12 +10,15 @@ import { anything, instance, mock, verify, when } from 'ts-mockito'; import { AccountFacade } from 'ish-core/facades/account.facade'; import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; +import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; import { findAllCustomElements, findAllDataTestingIDs } from 'ish-core/utils/dev/html-query-utils'; import { AddressComponent } from 'ish-shared/components/address/address/address.component'; import { FormlyCustomerAddressFormComponent } from 'ish-shared/formly-address-forms/components/formly-customer-address-form/formly-customer-address-form.component'; import { FormlyTestingModule } from 'ish-shared/formly/dev/testing/formly-testing.module'; +import { LazyAddressDoctorComponent } from '../../../../extensions/address-doctor/exports/lazy-address-doctor/lazy-address-doctor.component'; + import { BasketInvoiceAddressWidgetComponent } from './basket-invoice-address-widget.component'; describe('Basket Invoice Address Widget Component', () => { @@ -35,12 +38,18 @@ describe('Basket Invoice Address Widget Component', () => { when(accountFacade.isLoggedIn$).thenReturn(of(true)); await TestBed.configureTestingModule({ - imports: [FormlyTestingModule, RouterTestingModule, TranslateModule.forRoot()], + imports: [ + FeatureToggleModule.forTesting('addressDoctor'), + FormlyTestingModule, + RouterTestingModule, + TranslateModule.forRoot(), + ], declarations: [ BasketInvoiceAddressWidgetComponent, MockComponent(AddressComponent), MockComponent(FaIconComponent), MockComponent(FormlyCustomerAddressFormComponent), + MockComponent(LazyAddressDoctorComponent), MockDirective(NgbCollapse), ], providers: [ @@ -107,6 +116,7 @@ describe('Basket Invoice Address Widget Component', () => { [ "ish-address", "ish-formly-customer-address-form", + "ish-lazy-address-doctor", ] `); expect(findAllDataTestingIDs(fixture)).toMatchInlineSnapshot(` @@ -129,6 +139,7 @@ describe('Basket Invoice Address Widget Component', () => { [ "ish-address", "ish-formly-customer-address-form", + "ish-lazy-address-doctor", ] `); expect(findAllDataTestingIDs(fixture)).toMatchInlineSnapshot(` diff --git a/src/app/shared/components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component.ts b/src/app/shared/components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component.ts index 35c0398ec4..bd9ef4e87c 100644 --- a/src/app/shared/components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component.ts +++ b/src/app/shared/components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component.ts @@ -6,7 +6,9 @@ import { filter, map, shareReplay, take, takeUntil } from 'rxjs/operators'; import { AccountFacade } from 'ish-core/facades/account.facade'; import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; +import { FeatureToggleService } from 'ish-core/feature-toggle.module'; import { Address } from 'ish-core/models/address/address.model'; +import { FeatureEventService } from 'ish-core/utils/feature-event/feature-event.service'; import { whenTruthy } from 'ish-core/utils/operators'; import { FormsService } from 'ish-shared/forms/utils/forms.service'; @@ -42,7 +44,12 @@ export class BasketInvoiceAddressWidgetComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); - constructor(private checkoutFacade: CheckoutFacade, private accountFacade: AccountFacade) {} + constructor( + private checkoutFacade: CheckoutFacade, + private accountFacade: AccountFacade, + private featureToggleService: FeatureToggleService, + private featureEventService: FeatureEventService + ) {} ngOnInit() { this.customerAddresses$ = this.accountFacade.addresses$().pipe(shareReplay(1)); @@ -120,10 +127,41 @@ export class BasketInvoiceAddressWidgetComponent implements OnInit, OnDestroy { saveAddress(address: Address) { if (this.editAddress && Object.keys(this.editAddress).length > 0) { - this.checkoutFacade.updateBasketAddress(address); - this.collapse = true; + if (this.featureToggleService.enabled('addressDoctor')) { + const id = this.featureEventService.sendNotification('addressDoctor', 'check-address', { + address, + }); + + this.featureEventService + .eventResultListener$('addressDoctor', 'check-address', id) + .pipe(whenTruthy(), take(1), takeUntil(this.destroy$)) + .subscribe(({ data }) => { + if (data) { + this.checkoutFacade.updateBasketAddress(data); + this.collapse = true; + } + }); + } else { + this.checkoutFacade.updateBasketAddress(address); + this.collapse = true; + } } else { - this.checkoutFacade.createBasketAddress(address, 'invoice'); + if (this.featureToggleService.enabled('addressDoctor')) { + const id = this.featureEventService.sendNotification('addressDoctor', 'check-address', { + address, + }); + + this.featureEventService + .eventResultListener$('addressDoctor', 'check-address', id) + .pipe(whenTruthy(), take(1), takeUntil(this.destroy$)) + .subscribe(({ data }) => { + if (data) { + this.checkoutFacade.createBasketAddress(data, 'invoice'); + } + }); + } else { + this.checkoutFacade.createBasketAddress(address, 'invoice'); + } (this.form.get('id') as UntypedFormControl).setValue('', { emitEvent: false }); } } diff --git a/src/app/shared/components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component.html b/src/app/shared/components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component.html index 5671726549..50fd902e73 100644 --- a/src/app/shared/components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component.html +++ b/src/app/shared/components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component.html @@ -86,3 +86,7 @@

{{ 'checkout.address.shipping.label' | translate }}

> + + + + diff --git a/src/app/shared/components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component.spec.ts b/src/app/shared/components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component.spec.ts index d24c809456..ca35744fd3 100644 --- a/src/app/shared/components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component.spec.ts +++ b/src/app/shared/components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component.spec.ts @@ -10,6 +10,7 @@ import { anything, instance, mock, verify, when } from 'ts-mockito'; import { AccountFacade } from 'ish-core/facades/account.facade'; import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; +import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; import { findAllCustomElements, findAllDataTestingIDs } from 'ish-core/utils/dev/html-query-utils'; import { AddressComponent } from 'ish-shared/components/address/address/address.component'; @@ -17,6 +18,8 @@ import { ModalDialogComponent } from 'ish-shared/components/common/modal-dialog/ import { FormlyCustomerAddressFormComponent } from 'ish-shared/formly-address-forms/components/formly-customer-address-form/formly-customer-address-form.component'; import { FormlyTestingModule } from 'ish-shared/formly/dev/testing/formly-testing.module'; +import { LazyAddressDoctorComponent } from '../../../../extensions/address-doctor/exports/lazy-address-doctor/lazy-address-doctor.component'; + import { BasketShippingAddressWidgetComponent } from './basket-shipping-address-widget.component'; describe('Basket Shipping Address Widget Component', () => { @@ -38,12 +41,18 @@ describe('Basket Shipping Address Widget Component', () => { when(accountFacade.addresses$()).thenReturn(EMPTY); await TestBed.configureTestingModule({ - imports: [FormlyTestingModule, RouterTestingModule, TranslateModule.forRoot()], + imports: [ + FeatureToggleModule.forTesting('addressDoctor'), + FormlyTestingModule, + RouterTestingModule, + TranslateModule.forRoot(), + ], declarations: [ BasketShippingAddressWidgetComponent, MockComponent(AddressComponent), MockComponent(FaIconComponent), MockComponent(FormlyCustomerAddressFormComponent), + MockComponent(LazyAddressDoctorComponent), MockComponent(ModalDialogComponent), MockDirective(NgbCollapse), ], @@ -135,6 +144,7 @@ describe('Basket Shipping Address Widget Component', () => { "formly-field", "ng-component", "ish-formly-customer-address-form", + "ish-lazy-address-doctor", ] `); expect(findAllDataTestingIDs(fixture)).toMatchInlineSnapshot(` @@ -188,6 +198,7 @@ describe('Basket Shipping Address Widget Component', () => { "formly-field", "ng-component", "ish-formly-customer-address-form", + "ish-lazy-address-doctor", ] `); expect(findAllDataTestingIDs(fixture)).toMatchInlineSnapshot(` diff --git a/src/app/shared/components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component.ts b/src/app/shared/components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component.ts index a710a109ab..583b9d57d1 100644 --- a/src/app/shared/components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component.ts +++ b/src/app/shared/components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component.ts @@ -6,7 +6,9 @@ import { filter, map, shareReplay, take, takeUntil } from 'rxjs/operators'; import { AccountFacade } from 'ish-core/facades/account.facade'; import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; +import { FeatureToggleService } from 'ish-core/feature-toggle.module'; import { Address } from 'ish-core/models/address/address.model'; +import { FeatureEventService } from 'ish-core/utils/feature-event/feature-event.service'; import { whenTruthy } from 'ish-core/utils/operators'; import { FormsService } from 'ish-shared/forms/utils/forms.service'; @@ -46,7 +48,12 @@ export class BasketShippingAddressWidgetComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); - constructor(private accountFacade: AccountFacade, private checkoutFacade: CheckoutFacade) { + constructor( + private accountFacade: AccountFacade, + private checkoutFacade: CheckoutFacade, + private featureToggleService: FeatureToggleService, + private featureEventService: FeatureEventService + ) { this.form = new UntypedFormGroup({ id: new UntypedFormControl(''), }); @@ -135,10 +142,41 @@ export class BasketShippingAddressWidgetComponent implements OnInit, OnDestroy { saveAddress(address: Address) { if (this.editAddress && Object.keys(this.editAddress).length > 0) { - this.checkoutFacade.updateBasketAddress(address); - this.collapse = true; + if (this.featureToggleService.enabled('addressDoctor')) { + const id = this.featureEventService.sendNotification('addressDoctor', 'check-address', { + address, + }); + + this.featureEventService + .eventResultListener$('addressDoctor', 'check-address', id) + .pipe(whenTruthy(), take(1), takeUntil(this.destroy$)) + .subscribe(({ data }) => { + if (data) { + this.checkoutFacade.updateBasketAddress(data); + this.collapse = true; + } + }); + } else { + this.checkoutFacade.updateBasketAddress(address); + this.collapse = true; + } } else { - this.checkoutFacade.createBasketAddress(address, 'shipping'); + if (this.featureToggleService.enabled('addressDoctor')) { + const id = this.featureEventService.sendNotification('addressDoctor', 'check-address', { + address, + }); + + this.featureEventService + .eventResultListener$('addressDoctor', 'check-address', id) + .pipe(whenTruthy(), take(1), takeUntil(this.destroy$)) + .subscribe(({ data }) => { + if (data) { + this.checkoutFacade.createBasketAddress(data, 'shipping'); + } + }); + } else { + this.checkoutFacade.createBasketAddress(address, 'shipping'); + } (this.form.get('id') as UntypedFormControl).setValue('', { emitEvent: false }); } } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 8403ce2e5f..6af17d0872 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -23,6 +23,7 @@ import { RoleToggleModule } from 'ish-core/role-toggle.module'; import { FeatureEventService } from 'ish-core/utils/feature-event/feature-event.service'; import { ModuleLoaderService } from 'ish-core/utils/module-loader/module-loader.service'; +import { AddressDoctorExportsModule } from '../extensions/address-doctor/exports/address-doctor-exports.module'; import { CaptchaExportsModule } from '../extensions/captcha/exports/captcha-exports.module'; import { CompareExportsModule } from '../extensions/compare/exports/compare-exports.module'; import { ContactUsExportsModule } from '../extensions/contact-us/exports/contact-us-exports.module'; @@ -148,6 +149,7 @@ import { FormlyModule } from './formly/formly.module'; import { FormsSharedModule } from './forms/forms.module'; const importExportModules = [ + AddressDoctorExportsModule, AuthorizationToggleModule, CMSModule, CaptchaExportsModule, diff --git a/src/assets/i18n/de_DE.json b/src/assets/i18n/de_DE.json index 22ca765da2..0d8a87baf1 100644 --- a/src/assets/i18n/de_DE.json +++ b/src/assets/i18n/de_DE.json @@ -549,6 +549,11 @@ "account.wishlists.wishlist_form.preferred.tooltip.content": "Die Auswahl markiert die Wunschliste als bevorzugte Liste und setzt alle anderen (wenn vorhanden) zurück auf Standard. Alle hinzugefügten Produkte werden standardmäßig zu dieser Liste hinzugefügt.", "account.wishlists.wishlist_form.preferred.tooltip.headline": "Als bevorzugt markieren", "account.wishlists.wishlist_form.preferred.tooltip.linktext": "Details", + "address.doctor.suggestion.address": "Aktuelle Adresse", + "address.doctor.suggestion.confirm": "Auswahl speichern", + "address.doctor.suggestion.proposals": "Vorschläge", + "address.doctor.suggestion.text": "

Wir haben Vorschläge für Ihre eingegebene Adresse. Bitte wählen Sie die zu verwendende Adresse aus.

", + "address.doctor.suggestion.title": "Vorschläge für Ihre Adresse", "application.recentlyViewed.breadcrumb.label": "Ihre Browsing-Historie", "application.recentlyViewed.clear": "Historie löschen", "application.recentlyViewed.empty": "Ihre Browsing-Historie ist leer", diff --git a/src/assets/i18n/en_US.json b/src/assets/i18n/en_US.json index 50a24bc2b8..5fa095e2cb 100644 --- a/src/assets/i18n/en_US.json +++ b/src/assets/i18n/en_US.json @@ -549,6 +549,11 @@ "account.wishlists.wishlist_form.preferred.tooltip.content": "Selection will make the wish list your preferred wish list and sets all other (if any) back to normal. All added products will be added by default to this wish list.", "account.wishlists.wishlist_form.preferred.tooltip.headline": "Set as Preferred", "account.wishlists.wishlist_form.preferred.tooltip.linktext": "Details", + "address.doctor.suggestion.address": "Current address", + "address.doctor.suggestion.confirm": "Save selection", + "address.doctor.suggestion.proposals": "Proposals", + "address.doctor.suggestion.text": "

We have suggestions for your entered address. Please select the address to be used.

", + "address.doctor.suggestion.title": "Suggestions for your address", "application.recentlyViewed.breadcrumb.label": "Your Browsing History", "application.recentlyViewed.clear": "Clear My History", "application.recentlyViewed.empty": "Your browsing history is empty", diff --git a/src/assets/i18n/fr_FR.json b/src/assets/i18n/fr_FR.json index aaa0aee4db..971912d3ad 100644 --- a/src/assets/i18n/fr_FR.json +++ b/src/assets/i18n/fr_FR.json @@ -549,6 +549,11 @@ "account.wishlists.wishlist_form.preferred.tooltip.content": "La sélection fera de la liste de souhaits votre liste de souhaits préférée et remettra tous les autres (s’il y en a) au statut normal. Tous les produits ajoutés seront ajoutés par défaut à cette liste de souhaits.", "account.wishlists.wishlist_form.preferred.tooltip.headline": "Définir comme préférence", "account.wishlists.wishlist_form.preferred.tooltip.linktext": "Détails", + "address.doctor.suggestion.address": "Adresse actuelle", + "address.doctor.suggestion.confirm": "Sauvegarder la sélection", + "address.doctor.suggestion.proposals": "Propositions", + "address.doctor.suggestion.text": "

Nous avons des suggestions pour votre adresse saisie. Veuillez sélectionner l’adresse à utiliser.

", + "address.doctor.suggestion.title": "Suggestions pour votre adresse", "application.recentlyViewed.breadcrumb.label": "Votre historique de navigation", "application.recentlyViewed.clear": "Effacer mon historique de navigation", "application.recentlyViewed.empty": "Votre historique de navigation est vide", diff --git a/src/environments/environment.model.ts b/src/environments/environment.model.ts index 42cb0732ef..559c67bec3 100644 --- a/src/environments/environment.model.ts +++ b/src/environments/environment.model.ts @@ -4,6 +4,7 @@ import { DeviceType, ViewType } from 'ish-core/models/viewtype/viewtype.types'; import { DataRetentionPolicy } from 'ish-core/utils/meta-reducers'; import { MultiSiteLocaleMap } from 'ish-core/utils/multi-site/multi-site.service'; +import { AddressDoctorConfig } from '../app/extensions/address-doctor/models/address-doctor/address-doctor-config.model'; import { TactonConfig } from '../app/extensions/tacton/models/tacton-config/tacton-config.model'; export interface Environment { @@ -42,6 +43,7 @@ export interface Environment { | 'guestCheckout' | 'wishlists' /* Third-party Integrations */ + | 'addressDoctor' | 'sentry' | 'tracking' | 'tacton' @@ -62,6 +64,9 @@ export interface Environment { // tacton integration tacton?: TactonConfig; + // address doctor integration + addressDoctor?: AddressDoctorConfig; + /* PROGRESSIVE WEB APP CONFIGURATIONS */ // Bootstrap grid system breakpoint widths as defined in the variables-bootstrap-customized.scss for usage in Javascript logic