Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support for CXML self service configuration #1683

Merged
merged 2 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/guides/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ If so these wrapper configurations need to be replaced with `maxlength-descripti
B2B users with the permission `APP_B2B_MANAGE_ORDERS` (only available for admin users in ICM 12.1.0 and higher) see now the orders of all users of the company on the My Account order history page.
They can filter the orders by buyer in order to see only e.g. the own orders again.

In preparation of the cXML punchout self service configuration we switched from a hidden route parameter that conveys the punchout type context information to an URL query parameter (e.g. `?format=cxml`).
So customized routing within the punchout area needs to be adapted accordingly.

## From 5.0 to 5.1

The OrderListComponent is strictly presentational, components using it have to supply the data.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ describe('Punchout MyAccount Functionality', () => {
page.submit();
});
at(PunchoutOverviewPage, page => {
page.selectOciTab();
page.userList.should('contain', `${_.punchoutUser2.login}`);
page.userList.should('not.contain', `Inactive`);
page.successMessage.message.should('contain', 'created');
Expand Down
4 changes: 4 additions & 0 deletions src/app/core/icon.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
faCalendarDay,
faCheck,
faCheckCircle,
faChevronDown,
faChevronUp,
faCog,
faCogs,
faEnvelope,
Expand Down Expand Up @@ -77,6 +79,8 @@ export class IconModule {
faCalendarDay,
faCheck,
faCheckCircle,
faChevronDown,
faChevronUp,
faCog,
faCogs,
faEnvelope,
Expand Down
56 changes: 56 additions & 0 deletions src/app/core/interceptors/icm-error-mapper.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,62 @@ describe('Icm Error Mapper Interceptor', () => {
);
});

it('should convert ICM errors format with cause (new ADR) to simplified format concatenating all causes', done => {
http.get('some').subscribe({
next: fail,
error: error => {
expect(error).toMatchInlineSnapshot(`
{
"errors": [
{
"causes": [
{
"code": "intershop.cxml.punchout.unitmapping.value.invalid",
"message": "The value must be a tab-separated list of 'value1;value2' pairs.",
},
{
"code": "intershop.cxml.punchout.punchout.locale.value.invalid",
"message": "The value must be two lowercase letters for language and two uppercase letters for region.",
},
],
"code": "intershop.cxml.punchout.configuration.error",
"level": "ERROR",
"status": "400",
},
],
"message": "<div>The value must be a tab-separated list of 'value1;value2' pairs.</div><div>The value must be two lowercase letters for language and two uppercase letters for region.</div>",
"name": "HttpErrorResponse",
"status": 422,
}
`);
done();
},
});

httpController.expectOne('some').flush(
{
messages: [
{
causes: [
{
code: 'intershop.cxml.punchout.unitmapping.value.invalid',
message: "The value must be a tab-separated list of 'value1;value2' pairs.",
},
{
code: 'intershop.cxml.punchout.punchout.locale.value.invalid',
message: 'The value must be two lowercase letters for language and two uppercase letters for region.',
},
],
code: 'intershop.cxml.punchout.configuration.error',
level: 'ERROR',
status: '400',
},
],
},
{ status: 422, statusText: 'Unprocessable Entity' }
);
});

it('should convert ICM errors format to simplified format', done => {
http.get('some').subscribe({
next: fail,
Expand Down
24 changes: 24 additions & 0 deletions src/app/core/interceptors/icm-error-mapper.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,30 @@ export class ICMErrorMapperInterceptor implements HttpInterceptor {
errors: httpError.error?.errors,
};
}
// new ADR - used for cXML Punchout configuration
else if (httpError.error?.messages?.length) {
const errors: {
code: string;
causes?: {
code: string;
message: string;
}[];
}[] = httpError.error?.messages;
if (errors.length === 1) {
const error = errors[0];
if (error.causes?.length) {
return {
...responseError,
errors: httpError.error.messages,
message: error.causes.map(c => '<div>'.concat(c.message).concat('</div>')).join(''),
};
}
}
return {
...responseError,
errors: httpError.error?.errors,
};
}

// handle all other error responses with error object
return {
Expand Down
28 changes: 26 additions & 2 deletions src/app/extensions/punchout/facades/punchout.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ import { Observable, combineLatest } from 'rxjs';
import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';

import { HttpError } from 'ish-core/models/http-error/http-error.model';
import { selectRouteParam } from 'ish-core/store/core/router';
import { selectQueryParam } from 'ish-core/store/core/router';
import { decamelizeString } from 'ish-core/utils/functions';
import { whenTruthy } from 'ish-core/utils/operators';

import { CxmlConfiguration } from '../models/cxml-configuration/cxml-configuration.model';
import { OciConfigurationItem } from '../models/oci-configuration-item/oci-configuration-item.model';
import { PunchoutType, PunchoutUser } from '../models/punchout-user/punchout-user.model';
import {
cxmlConfigurationActions,
getCxmlConfiguration,
getCxmlConfigurationError,
getCxmlConfigurationLoading,
} from '../store/cxml-configuration';
import {
getOciConfiguration,
getOciConfigurationError,
Expand Down Expand Up @@ -45,7 +52,7 @@ export class PunchoutFacade {
supportedPunchoutTypes$: Observable<PunchoutType[]> = this.store.pipe(select(getPunchoutTypes));

selectedPunchoutType$ = combineLatest([
this.store.pipe(select(selectRouteParam('format'))),
this.store.pipe(select(selectQueryParam('format'))),
this.store.pipe(select(getPunchoutTypes)),
]).pipe(
filter(([format, types]) => !!format || types?.length > 0),
Expand Down Expand Up @@ -101,4 +108,21 @@ export class PunchoutFacade {
updateOciConfiguration(configuration: OciConfigurationItem[]) {
this.store.dispatch(ociConfigurationActions.updateOCIConfiguration({ configuration }));
}

cxmlConfiguration$() {
this.store.dispatch(cxmlConfigurationActions.loadCXMLConfiguration());
return this.store.pipe(select(getCxmlConfiguration));
}

cxmlConfigurationLoading$ = this.store.pipe(select(getCxmlConfigurationLoading));

cxmlConfigurationError$ = this.store.pipe(select(getCxmlConfigurationError));

updateCxmlConfiguration(configuration: CxmlConfiguration[]) {
this.store.dispatch(cxmlConfigurationActions.updateCXMLConfiguration({ configuration }));
}

resetCxmlConfiguration() {
this.store.dispatch(cxmlConfigurationActions.resetCXMLConfiguration());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { CxmlConfigurationInputType } from './cxml-configuration.model';

export interface CxmlConfigurationData {
data: {
name: string;
value: string;
}[];
info?: {
suschneider marked this conversation as resolved.
Show resolved Hide resolved
metaData: {
name: string;
defaultValue?: string;
description?: string;
inputType?: CxmlConfigurationInputType;
}[];
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { CxmlConfigurationData } from './cxml-configuration.interface';
import { CxmlConfigurationMapper } from './cxml-configuration.mapper';

describe('Cxml Configuration Mapper', () => {
describe('fromData', () => {
it('should return Cxml Configuration when getting CxmlConfigurationData', () => {
expect(() => CxmlConfigurationMapper.fromData(undefined)).toThrow();
});

it('should map incoming data to model data', () => {
const data: CxmlConfigurationData = {
data: [
{
name: 'classificationCatalogID',
value: '1',
},
],
info: {
metaData: [
{
name: 'classificationCatalogID',
defaultValue: 'eCl@ass',
description: 'Enter the type of product classification catalog to be used, e.g. UNSPSC or eCl@ass.',
inputType: 'text-short',
},
],
},
};
const mapped = CxmlConfigurationMapper.fromData(data);
expect(mapped).toMatchInlineSnapshot(`
[
{
"defaultValue": "eCl@ass",
"description": "Enter the type of product classification catalog to be used, e.g. UNSPSC or eCl@ass.",
"inputType": "text-short",
"name": "classificationCatalogID",
"value": "1",
},
]
`);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';

import { CxmlConfigurationData } from './cxml-configuration.interface';
import { CxmlConfiguration } from './cxml-configuration.model';

@Injectable({ providedIn: 'root' })
export class CxmlConfigurationMapper {
static fromData(cxmlConfigurationData: CxmlConfigurationData): CxmlConfiguration[] {
if (cxmlConfigurationData) {
const { data, info } = cxmlConfigurationData;

return data?.map(cxmlConfiguration => {
const infoElement = info?.metaData.find(e => e.name === cxmlConfiguration.name);
return {
...cxmlConfiguration,
defaultValue: infoElement?.defaultValue,
description: infoElement?.description,
inputType: infoElement?.inputType,
};
});
} else {
throw new Error(`CxmlConfigurationData is required`);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface CxmlConfiguration {
name: string;
value: string;
defaultValue?: string;
description?: string;
inputType?: CxmlConfigurationInputType;
}

export type CxmlConfigurationInputType = 'text-short' | 'text-long';
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,27 @@
<div class="list-body">
<formly-form [model]="model$ | async" [fields]="fields$ | async" [form]="form" class="pt-1" />
</div>
<div class="row justify-content-end">
<button
type="submit"
class="btn btn-primary"
[disabled]="formDisabled"
data-testing-id="update-oci-configuration"
>
{{ 'account.update.button.label' | translate }}
</button>
<a [routerLink]="['/account/punchout', { format: 'oci' }]" class="btn btn-secondary">{{
'account.cancel.link' | translate
}}</a>
<div class="row">
<div class="button-group w-100 clearfix">
<div class="float-md-right">
<button
type="submit"
class="btn btn-primary"
[disabled]="formDisabled"
data-testing-id="update-oci-configuration"
>
{{ 'account.update.button.label' | translate }}
</button>
<a [routerLink]="['/account/punchout']" [queryParams]="{ format: 'oci' }" class="btn btn-secondary">{{
'account.cancel.link' | translate
}}</a>
</div>
<div class="float-md-left">
<a class="btn btn-link pl-md-0" [routerLink]="['/account/punchout']" [queryParams]="{ format: 'oci' }">{{
'account.punchout.configuration.back_to_list' | translate
}}</a>
</div>
</div>
</div>
</form>
</ng-container>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<ng-container *ngIf="selectedUser$ | async as user">
<h1>{{ 'account.punchout.configuration.heading' | translate }} - {{ user.login }}</h1>
<p>{{ 'account.punchout.cxml.configuration.helptext' | translate }}</p>

<ng-container *ngIf="cxmlConfiguration$ | async as cxmlConfiguration; else noConfiguration">
<ng-container *ngIf="cxmlConfiguration.length > 0" ; else noConfiguration>
<ish-cxml-configuration-form [cxmlConfiguration]="cxmlConfiguration" /> </ng-container
></ng-container>
<ng-template #noConfiguration>
<p>{{ 'account.punchout.cxml.configuration.no_configuration' | translate }}</p>
</ng-template>
</ng-container>
<ish-loading *ngIf="loading$ | async" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockComponent } from 'ng-mocks';
import { of } from 'rxjs';
import { instance, mock, when } from 'ts-mockito';

import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component';

import { PunchoutFacade } from '../../facades/punchout.facade';
import { CxmlConfiguration } from '../../models/cxml-configuration/cxml-configuration.model';
import { PunchoutUser } from '../../models/punchout-user/punchout-user.model';

import { AccountPunchoutCxmlConfigurationPageComponent } from './account-punchout-cxml-configuration-page.component';
import { CxmlConfigurationFormComponent } from './cxml-configuration-form/cxml-configuration-form.component';

describe('Account Punchout Cxml Configuration Page Component', () => {
let component: AccountPunchoutCxmlConfigurationPageComponent;
let fixture: ComponentFixture<AccountPunchoutCxmlConfigurationPageComponent>;
let element: HTMLElement;
let punchoutFacade: PunchoutFacade;

beforeEach(async () => {
punchoutFacade = mock(PunchoutFacade);
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [
AccountPunchoutCxmlConfigurationPageComponent,
MockComponent(CxmlConfigurationFormComponent),
MockComponent(LoadingComponent),
],
providers: [{ provide: PunchoutFacade, useFactory: () => instance(punchoutFacade) }],
}).compileComponents();
});

beforeEach(() => {
fixture = TestBed.createComponent(AccountPunchoutCxmlConfigurationPageComponent);
component = fixture.componentInstance;
element = fixture.nativeElement;

const user = {
login: '1',
} as PunchoutUser;
const cxmlConfiguration = [{ name: 'test', value: 'test value' }] as CxmlConfiguration[];
when(punchoutFacade.selectedPunchoutUser$).thenReturn(of(user));
when(punchoutFacade.cxmlConfiguration$()).thenReturn(of(cxmlConfiguration));
});

it('should be created', () => {
expect(component).toBeTruthy();
expect(element).toBeTruthy();
expect(() => fixture.detectChanges()).not.toThrow();
});

it('should display the configuration form after creation', () => {
fixture.detectChanges();
expect(element.querySelector('ish-cxml-configuration-form')).toBeTruthy();
});

it('should display a loading overlay if the configuration is loading', () => {
when(punchoutFacade.cxmlConfigurationLoading$).thenReturn(of(true));
fixture.detectChanges();
expect(element.querySelector('ish-loading')).toBeTruthy();
});
});
suschneider marked this conversation as resolved.
Show resolved Hide resolved
Loading
Loading