diff --git a/feature-libs/quote/assets/translations/en/quote.i18n.ts b/feature-libs/quote/assets/translations/en/quote.i18n.ts index 3312db7b7ae..fe9663490cd 100644 --- a/feature-libs/quote/assets/translations/en/quote.i18n.ts +++ b/feature-libs/quote/assets/translations/en/quote.i18n.ts @@ -46,7 +46,10 @@ export const quote = { status: 'Status', creationSuccess: 'Quote #{{ code }} created successfully', cart: 'Cart', - contactVendor: 'Contact Vendor', + }, + comments: { + title: 'Contact', + invalidComment: 'Invalid Input - Please type again...', }, details: { code: 'Quote ID', diff --git a/feature-libs/quote/components/config/augmented-config.model.ts b/feature-libs/quote/components/config/augmented-config.model.ts new file mode 100644 index 00000000000..1ba1337b4df --- /dev/null +++ b/feature-libs/quote/components/config/augmented-config.model.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { QuoteUIConfig } from './quote-ui.config'; + +declare module '@spartacus/core' { + interface Config extends QuoteUIConfig {} +} diff --git a/feature-libs/quote/components/config/default-quote-ui.config.ts b/feature-libs/quote/components/config/default-quote-ui.config.ts new file mode 100644 index 00000000000..d8f1e4d9fca --- /dev/null +++ b/feature-libs/quote/components/config/default-quote-ui.config.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { QuoteUIConfig } from './quote-ui.config'; + +export const defaultQuoteUIConfig: QuoteUIConfig = { + quote: { maxCharsForComments: 1000 }, +}; diff --git a/feature-libs/quote/components/details/vendor-contact/index.ts b/feature-libs/quote/components/config/index.ts similarity index 52% rename from feature-libs/quote/components/details/vendor-contact/index.ts rename to feature-libs/quote/components/config/index.ts index e2e829cb407..c239f6d73bd 100644 --- a/feature-libs/quote/components/details/vendor-contact/index.ts +++ b/feature-libs/quote/components/config/index.ts @@ -4,5 +4,5 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from './quote-details-vendor-contact.component'; -export * from './quote-details-vendor-contact.module'; +export * from './quote-ui.config'; +import './augmented-config.model'; diff --git a/feature-libs/quote/components/config/quote-ui.config.ts b/feature-libs/quote/components/config/quote-ui.config.ts new file mode 100644 index 00000000000..a8e6a088cd5 --- /dev/null +++ b/feature-libs/quote/components/config/quote-ui.config.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { Config } from '@spartacus/core'; + +export interface QuoteUIConfigFragment { + maxCharsForComments?: number; +} + +@Injectable({ + providedIn: 'root', + useExisting: Config, +}) +export abstract class QuoteUIConfig { + quote?: QuoteUIConfigFragment; +} diff --git a/feature-libs/quote/components/details/comment/index.ts b/feature-libs/quote/components/details/comment/index.ts new file mode 100644 index 00000000000..778538444b5 --- /dev/null +++ b/feature-libs/quote/components/details/comment/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './quote-details-comment.component'; +export * from './quote-details-comment.module'; diff --git a/feature-libs/quote/components/details/comment/quote-details-comment.component.html b/feature-libs/quote/components/details/comment/quote-details-comment.component.html new file mode 100644 index 00000000000..4164ab48021 --- /dev/null +++ b/feature-libs/quote/components/details/comment/quote-details-comment.component.html @@ -0,0 +1,19 @@ + +
+ + + {{ + 'quote.comments.title' | cxTranslate + }} +
+
+ +
+
diff --git a/feature-libs/quote/components/details/comment/quote-details-comment.component.spec.ts b/feature-libs/quote/components/details/comment/quote-details-comment.component.spec.ts new file mode 100644 index 00000000000..38a21697dfa --- /dev/null +++ b/feature-libs/quote/components/details/comment/quote-details-comment.component.spec.ts @@ -0,0 +1,277 @@ +import { Component, Input } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { EventService, I18nTestingModule } from '@spartacus/core'; +import { + Comment, + Quote, + QuoteDetailsReloadQueryEvent, + QuoteFacade, +} from '@spartacus/quote/root'; +import { + ICON_TYPE, + MessagingComponent, + MessagingConfigs, +} from '@spartacus/storefront'; +import { cold } from 'jasmine-marbles'; +import { Observable, of, throwError } from 'rxjs'; +import { createEmptyQuote } from '../../../core/testing/quote-test-utils'; +import { QuoteUIConfig } from '../../config'; +import { QuoteDetailsCommentComponent } from './quote-details-comment.component'; + +const QUOTE_CODE = 'q123'; + +@Component({ + selector: 'cx-messaging', + template: '', + providers: [ + { provide: MessagingComponent, useClass: MockCxMessagingComponent }, + ], +}) +class MockCxMessagingComponent { + @Input() messageEvents$: Observable>; + @Input() messagingConfigs?: MessagingConfigs; + resetForm(): void {} +} + +@Component({ + selector: 'cx-icon', + template: '', +}) +class MockCxIconComponent { + @Input() type: ICON_TYPE; +} + +describe('QuoteDetailsCommentComponent', () => { + let fixture: ComponentFixture; + let component: QuoteDetailsCommentComponent; + let mockedQuoteFacade: QuoteFacade; + let mockedEventService: EventService; + let quoteUiConfig: QuoteUIConfig; + + let quote: Quote; + + beforeEach( + waitForAsync(() => { + initTestData(); + initMocks(); + TestBed.configureTestingModule({ + imports: [I18nTestingModule], + declarations: [ + QuoteDetailsCommentComponent, + MockCxMessagingComponent, + MockCxIconComponent, + ], + providers: [ + { + provide: QuoteFacade, + useValue: mockedQuoteFacade, + }, + { + provide: EventService, + useValue: mockedEventService, + }, + { + provide: QuoteUIConfig, + useValue: quoteUiConfig, + }, + ], + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(QuoteDetailsCommentComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + spyOn(component.commentsComponent, 'resetForm'); + }); + + function initTestData() { + quote = createEmptyQuote(); + quote.code = QUOTE_CODE; + quoteUiConfig = { + quote: { maxCharsForComments: 5000 }, + }; + } + + function initMocks() { + mockedQuoteFacade = jasmine.createSpyObj('quoteFacade', [ + 'getQuoteDetails', + 'addQuoteComment', + ]); + asSpy(mockedQuoteFacade.getQuoteDetails).and.returnValue(of(quote)); + asSpy(mockedQuoteFacade.addQuoteComment).and.returnValue(of({})); + + mockedEventService = jasmine.createSpyObj('eventService', ['dispatch']); + } + + function asSpy(f: any) { + return f; + } + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render the messaging section by default', () => { + expect(fixture.debugElement.query(By.css('cx-messaging'))).not.toBeNull(); + }); + + it('should hide the comments area when clicking the toggle', () => { + clickCommentsToggle(fixture); + expect(fixture.debugElement.query(By.css('cx-messaging'))).toBeNull(); + }); + + it('should show the comments area when clicking the toggle', () => { + component.showComments = false; + clickCommentsToggle(fixture); + expect(fixture.debugElement.query(By.css('cx-messaging'))).not.toBeNull(); + }); + + it('should pipe empty quote comments to empty message events', () => { + component.messageEvents$ + .subscribe((messageEvent) => { + expect(messageEvent.length).toBe(0); + }) + .unsubscribe(); + }); + + it('should pipe quote comments to message events', () => { + quote.comments = []; + quote.comments.push({}); + quote.comments.push({}); + component.messageEvents$ + .subscribe((messageEvent) => { + expect(messageEvent.length).toBe(2); + }) + .unsubscribe(); + }); + + function clickCommentsToggle( + fixture: ComponentFixture + ) { + fixture.debugElement + .query(By.css('.quote-comment-toggle')) + .nativeElement.click(); + fixture.detectChanges(); + } + describe('messagingConfigs', () => { + it('should be provided', () => { + expect(component.messagingConfigs).toBeDefined(); + }); + it('should set chars limit to default 1000 when not provided via config', () => { + quoteUiConfig.quote = undefined; + // re-create component so changed config is evaluated + fixture = TestBed.createComponent(QuoteDetailsCommentComponent); + expect(fixture.componentInstance.messagingConfigs.charactersLimit).toBe( + 1000 + ); + }); + it('should set chars limit from config', () => { + expect(component.messagingConfigs.charactersLimit).toBe(5000); + }); + it('should define a date format', () => { + expect(component.messagingConfigs.dateFormat).toBe( + 'MMMM d, yyyy h:mm aa' + ); + }); + it('should display add section for editable quotes', () => { + quote.isEditable = true; + (component.messagingConfigs.displayAddMessageSection ?? of(false)) + .subscribe((showAddSection) => { + expect(showAddSection).toBe(true); + }) + .unsubscribe(); + }); + it('should hide display add section for not editable quotes', () => { + quote.isEditable = false; + (component.messagingConfigs.displayAddMessageSection ?? of(true)) + .subscribe((showAddSection) => { + expect(showAddSection).toBe(false); + }) + .unsubscribe(); + }); + }); + + describe('mapCommentToMessageEvent', () => { + const comment = { + text: 'comment text', + creationDate: new Date('2022-10-03T17:33:45'), + fromCustomer: false, + author: { uid: 'cust_1', name: 'John Doe' }, + }; + + function mapCommentToMessageEvent(comment: Comment) { + return component['mapCommentToMessageEvent'](comment); + } + + it('should map comment text', () => { + expect(mapCommentToMessageEvent(comment).text).toEqual('comment text'); + }); + it('should map creation date', () => { + expect(mapCommentToMessageEvent(comment).createdAt).toContain( + 'Mon Oct 03 2022 17:33:45' + ); + }); + it('should map author', () => { + expect(mapCommentToMessageEvent(comment).author).toEqual('John Doe'); + }); + it('should map fromCustomer to not rightAligned', () => { + comment.fromCustomer = true; + expect(mapCommentToMessageEvent(comment).rightAlign).toEqual(false); + }); + it('should map not fromCustomer to rightAligned', () => { + comment.fromCustomer = false; + expect(mapCommentToMessageEvent(comment).rightAlign).toEqual(true); + }); + it("shouldn't map anything to code", () => { + expect(mapCommentToMessageEvent(comment).code).toBeUndefined(); + }); + it("shouldn't map anything to attachments", () => { + expect(mapCommentToMessageEvent(comment).attachments).toBeUndefined(); + }); + }); + + describe('onSend', () => { + it('should add a quote comment with the given text', () => { + component.onSend({ message: 'test comment' }, QUOTE_CODE); + expect(mockedQuoteFacade.addQuoteComment).toHaveBeenCalledWith( + QUOTE_CODE, + { + text: 'test comment', + } + ); + }); + it('should refresh the quote to display the just added comment', () => { + component.onSend({ message: 'test comment' }, QUOTE_CODE); + expect(mockedEventService.dispatch).toHaveBeenCalledWith( + {}, + QuoteDetailsReloadQueryEvent + ); + }); + it('should reset message input text', () => { + component.onSend({ message: 'test comment' }, QUOTE_CODE); + expect(component.commentsComponent.resetForm).toHaveBeenCalled(); + expect(component.messagingConfigs.newMessagePlaceHolder).toBeUndefined(); + }); + it('should handle errors', () => { + asSpy(mockedQuoteFacade.addQuoteComment).and.returnValue( + throwError(new Error('test error')) + ); + component.onSend({ message: 'test comment' }, QUOTE_CODE); + expect(component.commentsComponent.resetForm).toHaveBeenCalled(); + expect(component.messagingConfigs.newMessagePlaceHolder).toEqual( + 'quote.comments.invalidComment' + ); + }); + }); + + describe('prepareMessageEvents', () => { + it('should be able to handle undefined comments in model', () => { + const eventsObs = component['prepareMessageEvents'](); + expect(eventsObs).toBeObservable(cold('(a|)', { a: [] })); + }); + }); +}); diff --git a/feature-libs/quote/components/details/comment/quote-details-comment.component.ts b/feature-libs/quote/components/details/comment/quote-details-comment.component.ts new file mode 100644 index 00000000000..c9cbd0c6603 --- /dev/null +++ b/feature-libs/quote/components/details/comment/quote-details-comment.component.ts @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, ViewChild } from '@angular/core'; +import { EventService, TranslationService } from '@spartacus/core'; +import { + Comment, + QuoteDetailsReloadQueryEvent, + QuoteFacade, +} from '@spartacus/quote/root'; +import { + ICON_TYPE, + MessageEvent, + MessagingComponent, + MessagingConfigs, +} from '@spartacus/storefront'; +import { Observable } from 'rxjs'; +import { finalize, map, take } from 'rxjs/operators'; +import { QuoteUIConfig } from '../../config'; + +const DEFAULT_COMMENT_MAX_CHARS = 1000; + +@Component({ + selector: 'cx-quote-details-comment', + templateUrl: './quote-details-comment.component.html', +}) +export class QuoteDetailsCommentComponent { + @ViewChild(MessagingComponent) commentsComponent: MessagingComponent; + + showComments = true; + iconTypes = ICON_TYPE; + + quoteDetails$ = this.quoteFacade.getQuoteDetails(); + messageEvents$: Observable> = this.prepareMessageEvents(); + + messagingConfigs: MessagingConfigs = this.prepareMessagingConfigs(); + constructor( + protected quoteFacade: QuoteFacade, + protected eventService: EventService, + protected translationService: TranslationService, + protected quoteUiConfig: QuoteUIConfig + ) {} + + onSend(event: { message: string }, code: string) { + this.quoteFacade + .addQuoteComment(code, { text: event.message }) + .pipe( + take(1), + // do for error and success + finalize(() => this.commentsComponent.resetForm()) + ) + .subscribe( + // success + () => { + this.eventService.dispatch({}, QuoteDetailsReloadQueryEvent); + this.messagingConfigs.newMessagePlaceHolder = undefined; + }, + // error + () => { + this.translationService + .translate('quote.comments.invalidComment') + .pipe(take(1)) + .subscribe( + (text) => (this.messagingConfigs.newMessagePlaceHolder = text) + ); + } + ); + } + + protected prepareMessagingConfigs(): MessagingConfigs { + return { + charactersLimit: + this.quoteUiConfig.quote?.maxCharsForComments ?? + DEFAULT_COMMENT_MAX_CHARS, + displayAddMessageSection: this.quoteDetails$.pipe( + map((quote) => quote.isEditable) + ), + dateFormat: 'MMMM d, yyyy h:mm aa', + }; + } + + protected prepareMessageEvents(): Observable { + return this.quoteDetails$.pipe( + map((quote) => { + const messageEvents: MessageEvent[] = []; + quote.comments?.forEach((comment) => + messageEvents.push(this.mapCommentToMessageEvent(comment)) + ); + return messageEvents; + }) + ); + } + + protected mapCommentToMessageEvent(comment: Comment): MessageEvent { + const messages: MessageEvent = { + author: comment.author?.name, + text: comment.text, + createdAt: comment.creationDate?.toString(), + rightAlign: !comment.fromCustomer, + }; + return messages; + } +} diff --git a/feature-libs/quote/components/details/comment/quote-details-comment.module.ts b/feature-libs/quote/components/details/comment/quote-details-comment.module.ts new file mode 100644 index 00000000000..6ba6a55cd24 --- /dev/null +++ b/feature-libs/quote/components/details/comment/quote-details-comment.module.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { + AuthGuard, + CmsConfig, + I18nModule, + provideDefaultConfig, +} from '@spartacus/core'; +import { ChatMessagingModule, IconModule } from '@spartacus/storefront'; +import { QuoteDetailsCommentComponent } from './quote-details-comment.component'; + +@NgModule({ + imports: [CommonModule, I18nModule, IconModule, ChatMessagingModule], + providers: [ + provideDefaultConfig({ + cmsComponents: { + QuoteDetailsCommentComponent: { + component: QuoteDetailsCommentComponent, + guards: [AuthGuard], + }, + }, + }), + ], + declarations: [QuoteDetailsCommentComponent], + exports: [QuoteDetailsCommentComponent], +}) +export class QuoteDetailsCommentModule {} diff --git a/feature-libs/quote/components/details/index.ts b/feature-libs/quote/components/details/index.ts index b3e7f41931e..bd1252c402a 100644 --- a/feature-libs/quote/components/details/index.ts +++ b/feature-libs/quote/components/details/index.ts @@ -6,4 +6,4 @@ export * from './cart'; export * from './overview'; -export * from './vendor-contact'; +export * from './comment'; diff --git a/feature-libs/quote/components/details/overview/quote-details-overview.component.spec.ts b/feature-libs/quote/components/details/overview/quote-details-overview.component.spec.ts index 1526d0afb73..3102d22654a 100644 --- a/feature-libs/quote/components/details/overview/quote-details-overview.component.spec.ts +++ b/feature-libs/quote/components/details/overview/quote-details-overview.component.spec.ts @@ -36,6 +36,7 @@ const mockQuote: Quote = { state: QuoteState.BUYER_ORDERED, name: 'Name', totalPrice: { value: 20, formattedValue: totalPriceFormattedValue }, + isEditable: true, }; export class MockQuoteFacade implements Partial { @@ -172,12 +173,5 @@ describe('QuoteDetailsOverviewComponent', () => { 'quote.details.total' ); }); - - it('should be able to deal with undefined actions', () => { - const quoteWoActions: Quote = { ...mockQuote, allowedActions: undefined }; - expect(component.getTotalPriceDescription(quoteWoActions)).toBe( - 'quote.details.estimatedTotal' - ); - }); }); }); diff --git a/feature-libs/quote/components/details/overview/quote-details-overview.component.ts b/feature-libs/quote/components/details/overview/quote-details-overview.component.ts index e9e8dc6a64a..7cbde9be137 100644 --- a/feature-libs/quote/components/details/overview/quote-details-overview.component.ts +++ b/feature-libs/quote/components/details/overview/quote-details-overview.component.ts @@ -47,7 +47,7 @@ export class QuoteDetailsOverviewComponent { * @returns 'Total' price if quote is in final state, 'Estimated total' otherwise */ getTotalPriceDescription(quote: Quote): string { - const readyToSubmit = quote.allowedActions?.find( + const readyToSubmit = quote.allowedActions.find( (action) => action.type === QuoteActionType.CHECKOUT ); return readyToSubmit diff --git a/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.component.html b/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.component.html deleted file mode 100644 index 713b0d1c6fa..00000000000 --- a/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.component.html +++ /dev/null @@ -1,15 +0,0 @@ -
- - - {{ - 'quote.commons.contactVendor' | cxTranslate - }} -
- -
{{ vendorplaceHolder }}
diff --git a/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.component.ts b/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.component.ts deleted file mode 100644 index 43d138dbe54..00000000000 --- a/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 SAP Spartacus team - * - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Component } from '@angular/core'; -import { QuoteFacade } from '@spartacus/quote/root'; -import { EventService } from '@spartacus/core'; -import { ICON_TYPE, MessagingConfigs } from '@spartacus/storefront'; - -@Component({ - selector: 'cx-quote-details-vendor-contact', - templateUrl: './quote-details-vendor-contact.component.html', -}) -export class QuoteDetailsVendorContactComponent { - quoteDetails$ = this.quoteFacade.getQuoteDetailsQueryState(); - showVendorContact = true; - iconTypes = ICON_TYPE; - vendorplaceHolder: string = 'Vendor Contact Component'; - - messagingConfigs: MessagingConfigs = this.prepareMessagingConfigs(); - constructor( - protected quoteFacade: QuoteFacade, - protected eventService: EventService - ) {} - onSend() { - //TODO CHHI signature is event: { message: string } - } - protected prepareMessagingConfigs(): MessagingConfigs { - return { - charactersLimit: 20, - }; - } -} diff --git a/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.module.ts b/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.module.ts deleted file mode 100644 index 31802fad2b6..00000000000 --- a/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.module.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 SAP Spartacus team - * - * SPDX-License-Identifier: Apache-2.0 - */ - -import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; -import { - AuthGuard, - CmsConfig, - I18nModule, - provideDefaultConfig, -} from '@spartacus/core'; -import { CardModule, IconModule, SpinnerModule } from '@spartacus/storefront'; -import { QuoteDetailsVendorContactComponent } from './quote-details-vendor-contact.component'; - -@NgModule({ - imports: [CommonModule, CardModule, I18nModule, IconModule, SpinnerModule], - providers: [ - provideDefaultConfig({ - cmsComponents: { - QuoteContactVendorComponent: { - component: QuoteDetailsVendorContactComponent, - guards: [AuthGuard], - }, - }, - }), - ], - declarations: [QuoteDetailsVendorContactComponent], - exports: [QuoteDetailsVendorContactComponent], -}) -export class QuoteDetailsVendorContactModule {} diff --git a/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.spec.ts b/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.spec.ts index 968e808855b..55df53fdd87 100644 --- a/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.spec.ts +++ b/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.spec.ts @@ -232,18 +232,6 @@ describe('QuoteActionsByRoleComponent', () => { expect(actionButtons[0].nativeElement.disabled).toBe(false); }); - it('should be able to deal with undefined actions', () => { - const quoteWithoutActions: Quote = { - ...mockQuote, - allowedActions: undefined, - }; - mockQuoteDetails$.next(quoteWithoutActions); - fixture.detectChanges(); - const actionButtons = fixture.debugElement.queryAll(By.css('.btn')); - expect(actionButtons).toBeDefined(); - expect(actionButtons.length).toBe(0); - }); - it('should let submit button enabled if threshold is not specified', () => { mockQuote.threshold = undefined; mockQuoteDetails$.next(submittableQuote); @@ -322,13 +310,10 @@ describe('QuoteActionsByRoleComponent', () => { const actionButtons = fixture.debugElement.queryAll(By.css('.btn')); expect(actionButtons).toBeDefined(); - actionButtons.filter((button, index) => { - if (mockQuote.allowedActions) { - expect(button.nativeElement.textContent.trim()).toEqual( - `quote.actions.${mockQuote.allowedActions[index].type}` - ); - } + expect(button.nativeElement.textContent.trim()).toEqual( + `quote.actions.${mockQuote.allowedActions[index].type}` + ); button.nativeElement.click(); }); expect(facade.performQuoteAction).toHaveBeenCalledWith( diff --git a/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.ts b/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.ts index 791a0cbb604..79ddee32680 100644 --- a/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.ts +++ b/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.ts @@ -41,7 +41,7 @@ export class QuoteActionsByRoleComponent implements OnInit, OnDestroy { ngOnInit(): void { //submit button present and threshold not reached: Display message this.quoteDetails$.pipe(take(1)).subscribe((quote) => { - const mustDisableAction = quote.allowedActions?.find((action) => + const mustDisableAction = quote.allowedActions.find((action) => this.mustDisableAction(action.type, quote) ); if (mustDisableAction) { diff --git a/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.module.ts b/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.module.ts index 3f2c9a12e21..e6f267daca8 100644 --- a/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.module.ts +++ b/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.module.ts @@ -6,7 +6,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; -import { QuoteConfig } from '@spartacus/quote/core'; +import { QuoteCoreConfig } from '@spartacus/quote/core'; import { QuoteActionType } from '@spartacus/quote/root'; import { AuthGuard, @@ -19,7 +19,7 @@ import { QuoteActionsByRoleComponent } from './quote-actions-by-role.component'; @NgModule({ imports: [CommonModule, I18nModule], providers: [ - provideDefaultConfig({ + provideDefaultConfig({ quote: { actions: { primaryActions: [ @@ -29,13 +29,14 @@ import { QuoteActionsByRoleComponent } from './quote-actions-by-role.component'; ], actionsOrderByState: { BUYER_DRAFT: [QuoteActionType.CANCEL, QuoteActionType.SUBMIT], - CANCELLED: [QuoteActionType.REQUOTE], - SELLER_REQUEST: [QuoteActionType.EDIT, QuoteActionType.SUBMIT], BUYER_OFFER: [ QuoteActionType.CANCEL, QuoteActionType.EDIT, QuoteActionType.CHECKOUT, ], + CANCELLED: [QuoteActionType.REQUOTE], + SELLER_REQUEST: [QuoteActionType.EDIT, QuoteActionType.SUBMIT], + SELLER_DRAFT: [QuoteActionType.SUBMIT], }, }, }, diff --git a/feature-libs/quote/components/quote-components.module.ts b/feature-libs/quote/components/quote-components.module.ts index ddeaef2db41..8b370381314 100644 --- a/feature-libs/quote/components/quote-components.module.ts +++ b/feature-libs/quote/components/quote-components.module.ts @@ -15,8 +15,10 @@ import { QuoteRequestDialogModule } from './quote-request-dialog/quote-request-d import { QuoteDetailsCartModule, QuoteDetailsOverviewModule, - QuoteDetailsVendorContactModule, + QuoteDetailsCommentModule, } from './details'; +import { provideDefaultConfig } from '@spartacus/core'; +import { defaultQuoteUIConfig } from './config/default-quote-ui.config'; @NgModule({ imports: [ @@ -28,8 +30,9 @@ import { QuoteRequestDialogModule, QuoteActionLinksModule, QuoteActionsByRoleModule, - QuoteDetailsVendorContactModule, + QuoteDetailsCommentModule, ListNavigationModule, ], + providers: [provideDefaultConfig(defaultQuoteUIConfig)], }) export class QuoteComponentsModule {} diff --git a/feature-libs/quote/components/quote-request-button/quote-request-button.component.ts b/feature-libs/quote/components/quote-request-button/quote-request-button.component.ts index 75ab3984008..deae5619e9c 100644 --- a/feature-libs/quote/components/quote-request-button/quote-request-button.component.ts +++ b/feature-libs/quote/components/quote-request-button/quote-request-button.component.ts @@ -34,12 +34,7 @@ export class QuoteRequestButtonComponent implements OnDestroy { goToQuoteDetails(): void { this.subscription.add( this.quoteFacade - .createQuote( - {}, - { - text: 'sometext', - } - ) + .createQuote({}) .pipe( tap((quote) => { this.routingService.go({ diff --git a/feature-libs/quote/components/quote-request-dialog/quote-confirm-request-dialog/quote-confirm-request-dialog.component.ts b/feature-libs/quote/components/quote-request-dialog/quote-confirm-request-dialog/quote-confirm-request-dialog.component.ts index 31b38a5695a..26732759ab9 100644 --- a/feature-libs/quote/components/quote-request-dialog/quote-confirm-request-dialog/quote-confirm-request-dialog.component.ts +++ b/feature-libs/quote/components/quote-request-dialog/quote-confirm-request-dialog/quote-confirm-request-dialog.component.ts @@ -5,7 +5,7 @@ */ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { QuoteConfig } from '@spartacus/quote/core'; +import { QuoteCoreConfig } from '@spartacus/quote/core'; import { FocusConfig, ICON_TYPE, @@ -33,7 +33,7 @@ export class QuoteConfirmRequestDialogComponent implements OnInit { constructor( protected launchDialogService: LaunchDialogService, - protected config: QuoteConfig + protected config: QuoteCoreConfig ) {} ngOnInit(): void { diff --git a/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.spec.ts b/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.spec.ts index 343d31e94bd..6b894ed3088 100644 --- a/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.spec.ts +++ b/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.spec.ts @@ -158,14 +158,9 @@ describe('QuoteRequestDialogComponent', () => { .query(By.css('button.btn-action')) .nativeElement.click(); - expect(quoteFacade.createQuote).toHaveBeenCalledWith( - { - name: testForm.name, - }, - { - text: testForm.comment, - } - ); + expect(quoteFacade.createQuote).toHaveBeenCalledWith({ + name: testForm.name, + }); expect(routingService.go).toHaveBeenCalledWith({ cxRoute: 'quoteDetails', params: { quoteId: quoteCode }, @@ -192,14 +187,9 @@ describe('QuoteRequestDialogComponent', () => { .query(By.css('button.btn-primary')) .nativeElement.click(); - expect(quoteFacade.createQuote).toHaveBeenCalledWith( - { - name: testForm.name, - }, - { - text: testForm.comment, - } - ); + expect(quoteFacade.createQuote).toHaveBeenCalledWith({ + name: testForm.name, + }); expect(quoteFacade.performQuoteAction).toHaveBeenCalledWith( quoteCode, diff --git a/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.ts b/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.ts index 0d79c52d977..e89cf0f047f 100644 --- a/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.ts +++ b/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.ts @@ -6,7 +6,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { QuoteConfig } from '@spartacus/quote/core'; +import { QuoteCoreConfig } from '@spartacus/quote/core'; import { QuoteFacade, QuoteActionType, @@ -48,7 +48,7 @@ export class QuoteRequestDialogComponent { constructor( protected quoteFacade: QuoteFacade, protected routingService: RoutingService, - protected config: QuoteConfig, + protected config: QuoteCoreConfig, protected launchDialogService: LaunchDialogService ) {} @@ -70,9 +70,7 @@ export class QuoteRequestDialogComponent { } this.requestInProgress$.next(true); this.quoteFacade - .createQuote(quoteCreationPayload, { - text: this.form.controls.comment.value, - }) + .createQuote(quoteCreationPayload) .pipe( tap((quote) => { if (goToDetails) { diff --git a/feature-libs/quote/core/config/index.ts b/feature-libs/quote/core/config/index.ts index 6a4581d760b..1de139c08c5 100644 --- a/feature-libs/quote/core/config/index.ts +++ b/feature-libs/quote/core/config/index.ts @@ -4,4 +4,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from './quote-config'; +export * from './quote-core.config'; diff --git a/feature-libs/quote/core/config/quote-config.ts b/feature-libs/quote/core/config/quote-core.config.ts similarity index 92% rename from feature-libs/quote/core/config/quote-config.ts rename to feature-libs/quote/core/config/quote-core.config.ts index fd9a8a6ba23..b1b12cfe295 100644 --- a/feature-libs/quote/core/config/quote-config.ts +++ b/feature-libs/quote/core/config/quote-core.config.ts @@ -29,7 +29,7 @@ export interface QuoteActionsConfig { providedIn: 'root', useExisting: Config, }) -export abstract class QuoteConfig { +export abstract class QuoteCoreConfig { /** * Commerce quotes config */ @@ -40,5 +40,5 @@ export abstract class QuoteConfig { } declare module '@spartacus/core' { - interface Config extends QuoteConfig {} + interface Config extends QuoteCoreConfig {} } diff --git a/feature-libs/quote/core/facade/quote.service.spec.ts b/feature-libs/quote/core/facade/quote.service.spec.ts index 3ae52a4fa92..0ca920ef36b 100644 --- a/feature-libs/quote/core/facade/quote.service.spec.ts +++ b/feature-libs/quote/core/facade/quote.service.spec.ts @@ -229,13 +229,12 @@ describe('QuoteService', () => { it('should call createQuote command', () => { service - .createQuote(mockMetadata, mockComment) + .createQuote(mockMetadata) .pipe(take(1)) .subscribe((quote) => { expect(connector.createQuote).toHaveBeenCalled(); expect(quote.code).toEqual(mockQuote.code); expect(connector.editQuote).toHaveBeenCalled(); - expect(connector.addComment).toHaveBeenCalled(); expect(multiCartFacade.loadCart).toHaveBeenCalled(); expect(eventService.dispatch).toHaveBeenCalled(); }); diff --git a/feature-libs/quote/core/facade/quote.service.ts b/feature-libs/quote/core/facade/quote.service.ts index c35cfa85343..15ba53751ed 100644 --- a/feature-libs/quote/core/facade/quote.service.ts +++ b/feature-libs/quote/core/facade/quote.service.ts @@ -53,12 +53,9 @@ export class QuoteService implements QuoteFacade { protected isActionPerforming$ = new BehaviorSubject(false); protected createQuoteCommand: Command< - { quoteMetadata: QuoteMetadata; quoteComment: Comment }, + { quoteMetadata: QuoteMetadata }, Quote - > = this.commandService.create< - { quoteMetadata: QuoteMetadata; quoteComment: Comment }, - Quote - >( + > = this.commandService.create<{ quoteMetadata: QuoteMetadata }, Quote>( (payload) => combineLatest([ this.userIdService.takeUserId(), @@ -76,11 +73,6 @@ export class QuoteService implements QuoteFacade { quote.code, payload.quoteMetadata ), - this.quoteConnector.addComment( - userId, - quote.code, - payload.quoteComment - ), ]), of(userId), of(quote) @@ -255,13 +247,9 @@ export class QuoteService implements QuoteFacade { protected multiCartService: MultiCartFacade ) {} - createQuote( - quoteMetadata: QuoteMetadata, - quoteComment: Comment - ): Observable { + createQuote(quoteMetadata: QuoteMetadata): Observable { return this.createQuoteCommand.execute({ quoteMetadata, - quoteComment, }); } diff --git a/feature-libs/quote/core/testing/quote-test-utils.ts b/feature-libs/quote/core/testing/quote-test-utils.ts index 2eb85e3d510..1ed011f2023 100644 --- a/feature-libs/quote/core/testing/quote-test-utils.ts +++ b/feature-libs/quote/core/testing/quote-test-utils.ts @@ -17,7 +17,7 @@ export function createEmptyQuote(): Quote { state: QuoteState.BUYER_DRAFT, allowedActions: [], totalPrice: {}, - comments: [], description: 'Quote description', + isEditable: true, }; } diff --git a/feature-libs/quote/occ/converters/occ-quote-action-normalizer.spec.ts b/feature-libs/quote/occ/converters/occ-quote-action-normalizer.spec.ts index a64b4f8539f..02ec438764f 100644 --- a/feature-libs/quote/occ/converters/occ-quote-action-normalizer.spec.ts +++ b/feature-libs/quote/occ/converters/occ-quote-action-normalizer.spec.ts @@ -1,122 +1,167 @@ -import { inject, TestBed } from '@angular/core/testing'; -import { QuoteConfig } from '@spartacus/quote/core'; +import { TestBed } from '@angular/core/testing'; +import { QuoteCoreConfig } from '@spartacus/quote/core'; import { OccQuote, Quote, - QuoteActionsByState, QuoteActionType, QuoteState, } from '@spartacus/quote/root'; -import { OccQuoteActionNormalizer } from './occ-quote-action-normalizer'; import { createEmptyQuote } from '../../core/testing/quote-test-utils'; +import { OccQuoteActionNormalizer } from './occ-quote-action-normalizer'; -const mockActionOrderByState: Partial = { - BUYER_DRAFT: [QuoteActionType.CANCEL, QuoteActionType.SUBMIT], -}; -const mockPrimaryAction = QuoteActionType.SUBMIT; -const mockState = QuoteState.BUYER_DRAFT; -const mockAllowedActions = [QuoteActionType.SUBMIT, QuoteActionType.CANCEL]; - -const mockQuote: OccQuote = { - ...createEmptyQuote(), - allowedActions: mockAllowedActions, - state: mockState, -}; - -const mockConvertedQuote: Quote = { - ...mockQuote, - allowedActions: [ - { type: QuoteActionType.CANCEL, isPrimary: false }, - { type: QuoteActionType.SUBMIT, isPrimary: true }, - ], -}; - -const MockCQConfig: Partial = { - quote: { - actions: { - actionsOrderByState: mockActionOrderByState, - primaryActions: [mockPrimaryAction], - }, - }, -}; +const SUBMIT_AND_CANCEL_UNORDERED = [ + QuoteActionType.SUBMIT, + QuoteActionType.CANCEL, +]; -describe('BudgetNormalizer', () => { +describe('OccQuoteActionNormalizer', () => { let service: OccQuoteActionNormalizer; + let occQuote: OccQuote; + let expectedQuote: Quote; + let quoteCoreConfig: QuoteCoreConfig; beforeEach(() => { + initTestData(); TestBed.configureTestingModule({ providers: [ OccQuoteActionNormalizer, - { provide: QuoteConfig, useValue: MockCQConfig }, + { provide: QuoteCoreConfig, useValue: quoteCoreConfig }, ], }); service = TestBed.inject(OccQuoteActionNormalizer); }); - it('should inject OccQuoteActionNormalizer', inject( - [OccQuoteActionNormalizer], - (budgetNormalizer: OccQuoteActionNormalizer) => { - expect(budgetNormalizer).toBeTruthy(); - } - )); - - it('should convert budget', () => { - const result = service.convert(mockQuote); - expect(result).toEqual(mockConvertedQuote); - }); - - it('should return unsorted list if order is not present', () => { - service['quoteConfig'] = { - quote: undefined, + function initTestData() { + occQuote = { + ...createEmptyQuote(), + allowedActions: SUBMIT_AND_CANCEL_UNORDERED, + state: QuoteState.BUYER_DRAFT, }; - expect(service['getOrderedActions'](mockState, mockAllowedActions)).toEqual( - mockAllowedActions - ); - - service['quoteConfig'] = { - quote: { - actions: undefined, - }, + expectedQuote = { + ...occQuote, + allowedActions: [ + { type: QuoteActionType.CANCEL, isPrimary: false }, + { type: QuoteActionType.SUBMIT, isPrimary: true }, + ], + isEditable: false, }; - expect(service['getOrderedActions'](mockState, mockAllowedActions)).toEqual( - mockAllowedActions - ); - - service['quoteConfig'] = { + quoteCoreConfig = { quote: { actions: { - actionsOrderByState: undefined, + actionsOrderByState: { + BUYER_DRAFT: [QuoteActionType.CANCEL, QuoteActionType.SUBMIT], + }, + primaryActions: [QuoteActionType.SUBMIT], }, }, }; - expect(service['getOrderedActions'](mockState, mockAllowedActions)).toEqual( - mockAllowedActions - ); + } + + it('should inject OccQuoteActionNormalizer', () => { + expect(service).toBeDefined(); }); - it('should set isPrimary to false if primaryActions config is not defined', () => { - const result = { type: mockAllowedActions[0], isPrimary: false }; + describe('convert', () => { + it('should convert OccQuote to Quote', () => { + const result = service.convert(occQuote); + expect(result).toEqual(expectedQuote); + }); - service['quoteConfig'] = { - quote: undefined, - }; - expect(service['getActionCategory'](mockAllowedActions[0])).toEqual(result); + it('should set isEditable to true if edit is allowed by backend', () => { + occQuote.allowedActions = [QuoteActionType.EDIT]; + expect(service.convert(occQuote).isEditable).toBe(true); + }); - service['quoteConfig'] = { - quote: { - actions: undefined, - }, - }; - expect(service['getActionCategory'](mockAllowedActions[0])).toEqual(result); + it('should set isEditable to false if edit is allowed by backend, but would require status change', () => { + occQuote.allowedActions = [QuoteActionType.EDIT]; + (quoteCoreConfig.quote?.actions?.actionsOrderByState ?? {}).BUYER_DRAFT = + [QuoteActionType.EDIT]; + expect(service.convert(occQuote).isEditable).toBe(false); + }); - service['quoteConfig'] = { - quote: { - actions: { - primaryActions: undefined, - }, - }, + it('should set isEditable to false in case occ does not return allowedActions', () => { + occQuote.allowedActions = undefined; + expect(service.convert(occQuote).isEditable).toBe(false); + }); + + it('should set allowedActions in quote to empty array in case occ does not return allowedActions', () => { + occQuote.allowedActions = undefined; + expect(service.convert(occQuote).allowedActions).toEqual([]); + }); + }); + + describe('getOrderedActions', () => { + it('should return sorted list according to config', () => { + const orderedActions = service['getOrderedActions']( + QuoteState.BUYER_DRAFT, + SUBMIT_AND_CANCEL_UNORDERED + ); + expect(orderedActions).toEqual([ + QuoteActionType.CANCEL, + QuoteActionType.SUBMIT, + ]); + }); + it('should return unsorted list if no quote config is given', () => { + quoteCoreConfig.quote = undefined; + const orderedActions = service['getOrderedActions']( + QuoteState.BUYER_DRAFT, + SUBMIT_AND_CANCEL_UNORDERED + ); + expect(orderedActions).toEqual(SUBMIT_AND_CANCEL_UNORDERED); + }); + it('should return unsorted list if no actions are defined in the config', () => { + (quoteCoreConfig?.quote ?? {}).actions = undefined; + const orderedActions = service['getOrderedActions']( + QuoteState.BUYER_DRAFT, + SUBMIT_AND_CANCEL_UNORDERED + ); + expect(orderedActions).toEqual(SUBMIT_AND_CANCEL_UNORDERED); + }); + + it('should return unsorted list if no actions by state are defined in the config', () => { + (quoteCoreConfig.quote?.actions ?? {}).actionsOrderByState = undefined; + const orderedActions = service['getOrderedActions']( + QuoteState.BUYER_DRAFT, + SUBMIT_AND_CANCEL_UNORDERED + ); + expect(orderedActions).toEqual(SUBMIT_AND_CANCEL_UNORDERED); + }); + }); + + describe('getActionCategory', () => { + const SUBMIT_NOT_PRIMARY_ACTION = { + type: QuoteActionType.SUBMIT, + isPrimary: false, }; - expect(service['getActionCategory'](mockAllowedActions[0])).toEqual(result); + it('should set isPrimary to true if action is defined as primary in the config', () => { + const actualResult = service['getActionCategory'](QuoteActionType.SUBMIT); + expect(actualResult).toEqual({ + type: QuoteActionType.SUBMIT, + isPrimary: true, + }); + }); + it('should set isPrimary to false action is not defined as primary in the config', () => { + const actualResult = service['getActionCategory'](QuoteActionType.CANCEL); + expect(actualResult).toEqual({ + type: QuoteActionType.CANCEL, + isPrimary: false, + }); + }); + it('should set isPrimary to false if no quote config is given', () => { + quoteCoreConfig.quote = undefined; + const actualResult = service['getActionCategory'](QuoteActionType.SUBMIT); + expect(actualResult).toEqual(SUBMIT_NOT_PRIMARY_ACTION); + }); + it('should set isPrimary to false if no actions are defined in the config', () => { + (quoteCoreConfig?.quote ?? {}).actions = undefined; + const actualResult = service['getActionCategory'](QuoteActionType.SUBMIT); + expect(actualResult).toEqual(SUBMIT_NOT_PRIMARY_ACTION); + }); + it('should set isPrimary to false if no primaryActions are defined in the config', () => { + (quoteCoreConfig?.quote?.actions ?? {}).primaryActions = undefined; + const actualResult = service['getActionCategory'](QuoteActionType.SUBMIT); + expect(actualResult).toEqual(SUBMIT_NOT_PRIMARY_ACTION); + }); }); }); diff --git a/feature-libs/quote/occ/converters/occ-quote-action-normalizer.ts b/feature-libs/quote/occ/converters/occ-quote-action-normalizer.ts index 2bd4ac2baf4..c8d4a9bae8c 100644 --- a/feature-libs/quote/occ/converters/occ-quote-action-normalizer.ts +++ b/feature-libs/quote/occ/converters/occ-quote-action-normalizer.ts @@ -5,23 +5,23 @@ */ import { Injectable } from '@angular/core'; -import { QuoteConfig } from '@spartacus/quote/core'; +import { Converter } from '@spartacus/core'; +import { QuoteCoreConfig } from '@spartacus/quote/core'; import { - QuoteAction, OccQuote, Quote, + QuoteAction, QuoteActionType, QuoteState, } from '@spartacus/quote/root'; -import { Converter } from '@spartacus/core'; @Injectable({ providedIn: 'root' }) export class OccQuoteActionNormalizer implements Converter { - constructor(private quoteConfig: QuoteConfig) {} + constructor(protected quoteConfig: QuoteCoreConfig) {} convert(source: OccQuote, target?: Quote): Quote { if (!target) { - target = { ...(source as any) } as Quote; + target = { ...source, allowedActions: [], isEditable: false }; } if (source.allowedActions && source.state) { @@ -30,6 +30,13 @@ export class OccQuoteActionNormalizer implements Converter { source.allowedActions ).map((action) => this.getActionCategory(action)); } + const switchToEditModeRequired = target.allowedActions?.find( + (quoteAction) => quoteAction.type === QuoteActionType.EDIT + ); + + target.isEditable = + !!source.allowedActions?.includes(QuoteActionType.EDIT) && + !switchToEditModeRequired; //TODO CONFIG_INTEGRATION have this code in a dedicated entry normalizer //TODO CONFIG_INTEGRATION introduce constant for quote in model (no enum) @@ -48,10 +55,10 @@ export class OccQuoteActionNormalizer implements Converter { protected getOrderedActions(state: QuoteState, list: QuoteActionType[]) { const order = this.quoteConfig.quote?.actions?.actionsOrderByState?.[state]; - return !order + return order ? list - : list .filter((item) => order.includes(item)) - .sort((a, b) => order.indexOf(a) - order.indexOf(b)); + .sort((a, b) => order.indexOf(a) - order.indexOf(b)) + : list; } } diff --git a/feature-libs/quote/occ/converters/occ-quote-entry-normalizer.ts b/feature-libs/quote/occ/converters/occ-quote-entry-normalizer.ts index 38c7aab1326..060f9cf9b33 100644 --- a/feature-libs/quote/occ/converters/occ-quote-entry-normalizer.ts +++ b/feature-libs/quote/occ/converters/occ-quote-entry-normalizer.ts @@ -14,11 +14,11 @@ import { @Injectable({ providedIn: 'root' }) export class OccQuoteEntryNormalizer implements Converter { - constructor(private converter: ConverterService) {} + constructor(protected converter: ConverterService) {} convert(source: OccQuote, target?: Quote): Quote { if (!target) { - target = { ...(source as any) } as Quote; + target = { ...source, allowedActions: [], isEditable: false }; } if (source.entries) { diff --git a/feature-libs/quote/root/config/index.ts b/feature-libs/quote/root/config/index.ts new file mode 100644 index 00000000000..a03323ced51 --- /dev/null +++ b/feature-libs/quote/root/config/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './quote.config'; diff --git a/feature-libs/quote/root/config/quote.config.ts b/feature-libs/quote/root/config/quote.config.ts new file mode 100644 index 00000000000..cba5bbcc3b3 --- /dev/null +++ b/feature-libs/quote/root/config/quote.config.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@spartacus/core'; + +export interface QuoteConfig {} + +declare module '@spartacus/core' { + interface Config { + quote?: QuoteConfig; + } +} diff --git a/feature-libs/quote/root/facade/quote.facade.ts b/feature-libs/quote/root/facade/quote.facade.ts index 050328b8b17..44feda6d8d6 100644 --- a/feature-libs/quote/root/facade/quote.facade.ts +++ b/feature-libs/quote/root/facade/quote.facade.ts @@ -46,10 +46,7 @@ export abstract class QuoteFacade { /** * Create quote with name and comment. */ - abstract createQuote( - quoteMetadata: QuoteMetadata, - quoteComment: Comment - ): Observable; + abstract createQuote(quoteMetadata: QuoteMetadata): Observable; /** * Edit quote name, description or expiry date. diff --git a/feature-libs/quote/root/model/quote.model.ts b/feature-libs/quote/root/model/quote.model.ts index a046aff47ce..1b583572c41 100644 --- a/feature-libs/quote/root/model/quote.model.ts +++ b/feature-libs/quote/root/model/quote.model.ts @@ -9,10 +9,10 @@ import { PaginationModel, Price, Principal, SortModel } from '@spartacus/core'; import { Observable } from 'rxjs'; export interface OccQuote { - allowedActions: QuoteActionType[]; + allowedActions?: QuoteActionType[]; cartId?: string; code: string; - comments: Comment[]; + comments?: Comment[]; creationTime?: Date; description: string; entries?: OrderEntry[]; @@ -33,7 +33,8 @@ export interface OccQuote { } export type Quote = Omit & { - allowedActions?: QuoteAction[]; + isEditable: boolean; + allowedActions: QuoteAction[]; }; export interface QuoteAction { diff --git a/feature-libs/quote/root/public_api.ts b/feature-libs/quote/root/public_api.ts index 61383daee47..6d2cd3b6559 100644 --- a/feature-libs/quote/root/public_api.ts +++ b/feature-libs/quote/root/public_api.ts @@ -9,3 +9,7 @@ export * from './quote-root.module'; export * from './model/index'; export * from './facade/index'; export * from './events/index'; + +/** AUGMENTABLE_TYPES_START */ +export { QuoteConfig } from './config/quote.config'; +/** AUGMENTABLE_TYPES_END */ diff --git a/feature-libs/quote/root/quote-root.module.ts b/feature-libs/quote/root/quote-root.module.ts index 7b5ea9ec506..1510cc40453 100644 --- a/feature-libs/quote/root/quote-root.module.ts +++ b/feature-libs/quote/root/quote-root.module.ts @@ -32,7 +32,7 @@ export function defaultQuoteComponentsConfig() { 'QuoteActionLinksComponent', 'QuoteActionsByRoleComponent', 'CommerceQuotesCartSummaryComponent', //TODO CHHI probably not needed - 'QuoteContactVendorComponent', + 'QuoteDetailsCommentComponent', ], }, }, diff --git a/feature-libs/quote/styles/_index.scss b/feature-libs/quote/styles/_index.scss index 115a5c43552..dc0e6c39451 100644 --- a/feature-libs/quote/styles/_index.scss +++ b/feature-libs/quote/styles/_index.scss @@ -5,14 +5,14 @@ @import './quote-details-cart'; @import './quote-action-links'; @import './quote-actions-by-role'; -@import './quote-details-vendor-contact'; +@import './quote-details-comment'; @import './quote-confirm-request-dialog'; @import './layout/index'; $quote-components-allowlist: cx-quote-list cx-quote-details-overview cx-quote-details-cart cx-quote-request-button cx-quote-action-links - cx-quote-request-dialog cx-quote-actions-by-role - cx-quote-details-vendor-contact cx-quote-confirm-request-dialog !default; + cx-quote-request-dialog cx-quote-actions-by-role cx-quote-details-comment + cx-quote-confirm-request-dialog !default; $skipComponentStyles: () !default; diff --git a/feature-libs/quote/styles/_quote-details-vendor-contact.scss b/feature-libs/quote/styles/_quote-details-comment.scss similarity index 66% rename from feature-libs/quote/styles/_quote-details-vendor-contact.scss rename to feature-libs/quote/styles/_quote-details-comment.scss index 170ca53ce2f..42374fa961f 100644 --- a/feature-libs/quote/styles/_quote-details-vendor-contact.scss +++ b/feature-libs/quote/styles/_quote-details-comment.scss @@ -1,16 +1,23 @@ -%cx-quote-details-vendor-contact { - .vendor-contact-toggle { +%cx-quote-details-comment { + .quote-comment-toggle { cursor: pointer; user-select: none; + cx-icon { margin-inline-start: 0.5rem; font-size: var(--cx-font-size, 1.125rem); font-weight: var(--cx-font-weight-semi); } - .vendor-contact-text { + .quote-comment-text { font-size: var(--cx-font-size, 1.125rem); font-weight: var(--cx-font-weight-semi); } } + + cx-messaging { + .container { + min-height: 0px; + } + } } diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/quote/quote.e2e-flaky.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/quote/quote.e2e-flaky.cy.ts index eee0fe027c9..f7f71898ee2 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/quote/quote.e2e-flaky.cy.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/quote/quote.e2e-flaky.cy.ts @@ -26,32 +26,32 @@ context('Quote', () => { cy.cxConfig(globalMessageSettings); cy.visit('/'); quote.login(EMAIL, PASSWORD, USER); + quote.registerGetQuoteRoute(POWERTOOLS); }); describe('Request quote process', () => { it('should display a message and disable submit button if threshold is not met', () => { quote.requestQuote(POWERTOOLS, testProductHammerDrilling, '1'); - quote.checkGlobalMessageDisplayed(true); - quote.checkSubmitButton(false); + quote.checkQuoteInDraftState(false, testProductHammerDrilling); }); - it('should display no message and enable submit button if threshold is met', () => { + it('should be possible(submit) if threshold is met', () => { quote.requestQuote(POWERTOOLS, testProductHammerDrilling, '30'); - quote.checkGlobalMessageDisplayed(false); - quote.checkSubmitButton(true); - }); - - it('should result in a quote in draft state', () => { - quote.requestQuote(POWERTOOLS, testProductHammerDrilling, '30'); - cy.get('.cx-quote-details-header-status').should('contain.text', 'Draft'); - }); - - it('should display quote entry details like product ID', () => { - quote.requestQuote(POWERTOOLS, testProductHammerDrilling, '1'); - cy.get('.cx-code').should('contain.text', testProductHammerDrilling); + quote.checkQuoteInDraftState(true, testProductHammerDrilling); + quote.addCommentAndWait( + 'Can you please make me a good offer for this large volume of goods?' + ); + quote.checkComment( + 0, + 'Can you please make me a good offer for this large volume of goods?' + ); + quote.submitQuote(); + quote.checkQuoteState('Submitted'); + quote.checkCommentsNotEditable(); }); }); + // these tests should be removed, as soon as the quote list navigation is part of the above process tests describe('Quote list', () => { it('should be accessible from My Account', () => { quote.navigateToQuoteListFromMyAccount(); diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/quote.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/quote.ts index 96d9d04095f..aa186fc2f40 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/quote.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/quote.ts @@ -7,6 +7,9 @@ import * as authentication from './auth-forms'; import * as common from './common'; +/** alias for GET Quote Route */ +export const GET_QUOTE_ALIAS = '@GET_QUOTE'; + /** * Sets quantity on PDP */ @@ -57,6 +60,30 @@ export function requestQuote( this.clickOnRequestQuoteInCart(); } +export function submitQuote(): void { + clickOnSubmitQuoteBtnOnQD(); + clickOnYesBtnOnQuoteSubmitPopUp(); +} + +/** + * Clicks on 'Submit Quote' on the quote overview page. + */ +export function clickOnSubmitQuoteBtnOnQD(): void { + cy.get('cx-quote-actions-by-role button.btn-primary') + .click() + .then(() => { + cy.get('cx-quote-confirm-request-dialog').should('be.visible'); + }); +} + +/** + * Clicks on 'Yes' on the quote confirm request dialog popup. + */ +export function clickOnYesBtnOnQuoteSubmitPopUp(): void { + cy.get('div.cx-dialog-item button.btn-primary').click(); + cy.wait(GET_QUOTE_ALIAS); +} + /** * Checks on the global message on the top of the page. */ @@ -79,6 +106,10 @@ export function checkSubmitButton(isEnabled: boolean): void { } } +export function checkCommentsNotEditable(): void { + cy.get('cx-quote-details-comment .cx-message-input').should('not.exist'); +} + /** * Checks presence of quote list */ @@ -120,3 +151,55 @@ export function navigateToQuoteListFromQuoteDetails() { }); }); } + +/** + * Checks the displayed quote, assuming that it is in draft state. + * @param meetsThreshold does the quote meet the threshold + * @param productId product id of a product which is part of the quote + */ +export function checkQuoteInDraftState( + meetsThreshold: boolean, + productId: string +) { + checkQuoteState('Draft'); + this.checkGlobalMessageDisplayed(!meetsThreshold); + this.checkSubmitButton(meetsThreshold); + cy.get('.cx-code').should('contain.text', productId); +} + +export function checkQuoteState(status: string) { + cy.get('cx-quote-details-overview h3.status').contains(status); +} + +/** + * Adds a header comment to the quote assuming that the quote is currently in edit mode + * @param text text to add + */ +export function addCommentAndWait(text: string) { + cy.get('cx-quote-details-comment .cx-message-input').within(() => { + cy.get('input').type(text); + cy.get('button').click(); + }); + cy.wait(GET_QUOTE_ALIAS); +} + +/** + * checks whether the given header comment is displayed on the given position + * @param index position of the comment, starting with 0 for the first comment. + * @param text text to be displayed + */ +export function checkComment(index: number, text: string) { + cy.get('cx-quote-details-comment .cx-message-card div[role="listitem"]') + .eq(index) + .should('contain.text', text); +} + +/** + * Register GET quote route. + */ +export function registerGetQuoteRoute(shopName: string) { + cy.intercept({ + method: 'GET', + path: `${Cypress.env('OCC_PREFIX')}/${shopName}/users/current/quotes/*`, + }).as(GET_QUOTE_ALIAS.substring(1)); // strip the '@' +} diff --git a/projects/storefrontapp/src/app/spartacus/features/quote-feature.module.ts b/projects/storefrontapp/src/app/spartacus/features/quote-feature.module.ts index e0237546927..1bf9ba262c0 100644 --- a/projects/storefrontapp/src/app/spartacus/features/quote-feature.module.ts +++ b/projects/storefrontapp/src/app/spartacus/features/quote-feature.module.ts @@ -11,12 +11,12 @@ import { } from '@spartacus/quote/assets'; import { provideConfig } from '@spartacus/core'; import { QuoteRootModule, QUOTE_FEATURE } from '@spartacus/quote/root'; -import { QuoteConfig } from '@spartacus/quote/core'; +import { QuoteCoreConfig } from '@spartacus/quote/core'; @NgModule({ imports: [QuoteRootModule], providers: [ - provideConfig({ + provideConfig({ quote: { //TODO CHHI: Delete when decision has been taken about quote request dialog // tresholds: {