diff --git a/feature-libs/quote/assets/translations/en/quote.i18n.ts b/feature-libs/quote/assets/translations/en/quote.i18n.ts index f675d8db819..48476c275fb 100644 --- a/feature-libs/quote/assets/translations/en/quote.i18n.ts +++ b/feature-libs/quote/assets/translations/en/quote.i18n.ts @@ -60,13 +60,18 @@ export const quote = { allProducts: 'All Products', }, details: { + information: 'Quote Information', code: 'Quote ID', + name: 'Name', created: 'Created', lastUpdated: 'Last Updated', estimatedTotal: 'Estimated Total', total: 'Total', description: 'Description', - expiryDate: 'Expiry Date', + estimateAndDate: 'Estimated & Date', + update: 'Update', + expirationTime: 'Expiry Date', + charactersLeft: 'characters left: {{count}}', }, links: { newCart: 'New Cart', diff --git a/feature-libs/quote/components/details/edit/index.ts b/feature-libs/quote/components/details/edit/index.ts new file mode 100644 index 00000000000..32edf64ace7 --- /dev/null +++ b/feature-libs/quote/components/details/edit/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './quote-details-edit.component'; +export * from './quote-details-edit.module'; diff --git a/feature-libs/quote/components/details/edit/quote-details-edit.component.html b/feature-libs/quote/components/details/edit/quote-details-edit.component.html new file mode 100644 index 00000000000..18cca36d8d3 --- /dev/null +++ b/feature-libs/quote/components/details/edit/quote-details-edit.component.html @@ -0,0 +1,66 @@ +
+ +
+ + + {{ 'quote.details.information' | cxTranslate }} + + +
+ +
+
+
+
+ {{ 'quote.details.name' | cxTranslate }} +
+
+ +
+
+
+
+ {{ 'quote.details.description' | cxTranslate }} +
+
+ +

+ {{ + 'quote.details.charactersLeft' + | cxTranslate + : { + count: getCharactersLeft( + 'description', + content.charactersLimit + ) + } + }} +

+
+
+
+
+
+ +
+ + +
+
+
diff --git a/feature-libs/quote/components/details/edit/quote-details-edit.component.spec.ts b/feature-libs/quote/components/details/edit/quote-details-edit.component.spec.ts new file mode 100644 index 00000000000..c568b3114eb --- /dev/null +++ b/feature-libs/quote/components/details/edit/quote-details-edit.component.spec.ts @@ -0,0 +1,258 @@ +import { Component, DebugElement, Input } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { I18nTestingModule } from '@spartacus/core'; +import { ICON_TYPE } from '@spartacus/storefront'; +import { CommonQuoteTestUtilsService } from '../../testing/common-quote-test-utils.service'; +import { + EditCard, + QuoteDetailsEditComponent, +} from './quote-details-edit.component'; + +const mockCard: EditCard = { + name: 'Quote name', + description: 'Here you could enter a long description for the current quote', + charactersLimit: 255, +}; + +@Component({ + selector: 'cx-icon', + template: '', +}) +class MockCxIconComponent { + @Input() type: ICON_TYPE; +} + +describe('QuoteDetailsEditComponent', () => { + let fixture: ComponentFixture; + let component: QuoteDetailsEditComponent; + let htmlElem: HTMLElement; + let debugElement: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [I18nTestingModule, ReactiveFormsModule], + declarations: [QuoteDetailsEditComponent, MockCxIconComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(QuoteDetailsEditComponent); + htmlElem = fixture.nativeElement; + debugElement = fixture.debugElement; + component = fixture.componentInstance; + component.content = mockCard; + fixture.detectChanges(); + + spyOn(component.saveCard, 'emit').and.callThrough(); + spyOn(component.cancelCard, 'emit').and.callThrough(); + }); + + it('should create and render component accordingly', () => { + expect(component).toBeTruthy(); + + CommonQuoteTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-card' + ); + + CommonQuoteTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-card-edit' + ); + + CommonQuoteTestUtilsService.expectElementToContainText( + expect, + htmlElem, + '.cx-card-title', + 'quote.details.information' + ); + + CommonQuoteTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-card-container' + ); + + CommonQuoteTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-card-label-container' + ); + + CommonQuoteTestUtilsService.expectNumberOfElementsPresent( + expect, + htmlElem, + '.cx-card-paragraph', + 2 + ); + + CommonQuoteTestUtilsService.expectNumberOfElementsPresent( + expect, + htmlElem, + '.cx-card-paragraph-title', + 2 + ); + + CommonQuoteTestUtilsService.expectNumberOfElementsPresent( + expect, + htmlElem, + '.form-group', + 2 + ); + + CommonQuoteTestUtilsService.expectNumberOfElementsPresent( + expect, + htmlElem, + 'input', + 1 + ); + + CommonQuoteTestUtilsService.expectNumberOfElementsPresent( + expect, + htmlElem, + 'textarea', + 1 + ); + + CommonQuoteTestUtilsService.expectElementToContainText( + expect, + htmlElem, + '.cx-card-paragraph-title', + 'quote.details.description', + 1 + ); + + CommonQuoteTestUtilsService.expectElementToContainText( + expect, + htmlElem, + '.cx-info-text', + 'quote.details.charactersLeft count:194' + ); + + CommonQuoteTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-card-button-container' + ); + + CommonQuoteTestUtilsService.expectElementToContainText( + expect, + htmlElem, + 'button.btn-tertiary', + 'common.cancel' + ); + + CommonQuoteTestUtilsService.expectElementToContainText( + expect, + htmlElem, + 'button.btn-secondary', + 'common.save' + ); + }); + + describe('handle action events', () => { + it('should emit cancel event', () => { + const cancelButton = CommonQuoteTestUtilsService.getNativeElement( + debugElement, + 'button.btn-tertiary' + ); + cancelButton.click(); + expect(component.cancelCard.emit).toHaveBeenCalled(); + }); + + it('should emit edit event for disabling edit mode', () => { + const saveButton = CommonQuoteTestUtilsService.getNativeElement( + debugElement, + 'button.btn-secondary' + ); + saveButton.click(); + expect(component.saveCard.emit).toHaveBeenCalled(); + }); + + it('should emit edit event with an edited name and disabling edit mode', () => { + const newTextForTitle1: any = 'New title for name'; + component.editForm.get('name')?.setValue(newTextForTitle1); + component.editForm.get('name')?.markAsDirty(); + fixture.detectChanges(); + const saveButton = CommonQuoteTestUtilsService.getNativeElement( + debugElement, + 'button.btn-secondary' + ); + saveButton.click(); + expect(component.saveCard.emit).toHaveBeenCalled(); + let arg: any = (component.saveCard.emit as any).calls.mostRecent() + .args[0]; + expect(arg.name).toEqual(newTextForTitle1); + }); + + it('should emit edit event with an edited name, description and disabling edit mode', () => { + const newTextForTitle1: any = 'New title for name'; + const newTextForTitle2: any = 'Here could be found a long description'; + component.editForm.get('name')?.setValue(newTextForTitle1); + component.editForm.get('name')?.markAsDirty(); + component.editForm.get('description')?.setValue(newTextForTitle2); + component.editForm.get('description')?.markAsDirty(); + fixture.detectChanges(); + const saveButton = CommonQuoteTestUtilsService.getNativeElement( + debugElement, + 'button.btn-secondary' + ); + saveButton.click(); + expect(component.saveCard.emit).toHaveBeenCalled(); + let arg: any = (component.saveCard.emit as any).calls.mostRecent() + .args[0]; + expect(arg.name).toEqual(newTextForTitle1); + expect(arg.description).toEqual(newTextForTitle2); + }); + }); + + describe('getCharactersLeft', () => { + function setValue(formControlName: string, value: any): void { + component.editForm.get(formControlName)?.setValue(value); + component.editForm.get(formControlName)?.markAsTouched(); + fixture.detectChanges(); + } + + it('should calculate left characters', () => { + const formControlName = 'description'; + setValue(formControlName, 'New title for name'); + + if (component.content.charactersLimit) { + let charactersLeft = + component.content.charactersLimit - + component.editForm.get(formControlName)?.value?.length; + expect( + component['getCharactersLeft']( + formControlName, + component.content.charactersLimit + ) + ).toBe(charactersLeft); + + charactersLeft = + component.content.charactersLimit - + component.editForm.get(formControlName)?.value?.length; + expect( + component['getCharactersLeft']( + formControlName, + component.content.charactersLimit + ) + ).toBe(charactersLeft); + + setValue(formControlName, ''); + + charactersLeft = + component.content.charactersLimit - + component.editForm.get(formControlName)?.value?.length; + expect( + component['getCharactersLeft']( + formControlName, + component.content.charactersLimit + ) + ).toBe(charactersLeft); + } + }); + }); +}); diff --git a/feature-libs/quote/components/details/edit/quote-details-edit.component.ts b/feature-libs/quote/components/details/edit/quote-details-edit.component.ts new file mode 100644 index 00000000000..869f35e1054 --- /dev/null +++ b/feature-libs/quote/components/details/edit/quote-details-edit.component.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { ICON_TYPE } from '@spartacus/storefront'; + +export interface SaveEvent { + name?: string; + description?: string; +} + +export interface EditCard { + name: string; + description: string; + charactersLimit?: number; +} + +@Component({ + selector: 'cx-quote-details-edit', + templateUrl: './quote-details-edit.component.html', +}) +export class QuoteDetailsEditComponent implements OnInit { + iconTypes = ICON_TYPE; + editForm: UntypedFormGroup = new UntypedFormGroup({}); + + @Output() + saveCard: EventEmitter = new EventEmitter(); + @Output() + cancelCard: EventEmitter = new EventEmitter(); + + @Input() + content: EditCard; + + /** + * Cancels the view of the edit card tile. + */ + cancel(): void { + this.cancelCard.emit(); + } + + /** + * Saves the edited card tile by throwing the save event + * with the edit mode set to 'false' and the edited data. + */ + save(): void { + const event: SaveEvent = {}; + + Object.keys(this.editForm.controls).forEach((control) => { + if (this.editForm.get(control)?.dirty) { + const value = this.editForm.get(control)?.value; + Object.defineProperty(event, control, { + value: value, + }); + } + }); + + this.saveCard.emit(event); + } + + protected getCharactersLeft( + formControlName: string, + charactersLimit: number + ): number { + return ( + charactersLimit - (this.editForm.get(formControlName)?.value?.length || 0) + ); + } + + protected defineUntypedFormControl(formControlName: string, value: string) { + this.editForm.addControl(formControlName, new UntypedFormControl('')); + this.editForm.get(formControlName)?.setValue(value); + } + + ngOnInit() { + this.defineUntypedFormControl('name', this.content.name); + this.defineUntypedFormControl('description', this.content.description); + } +} diff --git a/feature-libs/quote/components/details/edit/quote-details-edit.module.ts b/feature-libs/quote/components/details/edit/quote-details-edit.module.ts new file mode 100644 index 00000000000..63ee6a5a149 --- /dev/null +++ b/feature-libs/quote/components/details/edit/quote-details-edit.module.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { I18nModule } from '@spartacus/core'; +import { IconModule, KeyboardFocusModule } from '@spartacus/storefront'; +import { QuoteDetailsEditComponent } from './quote-details-edit.component'; + +@NgModule({ + imports: [ + CommonModule, + I18nModule, + IconModule, + KeyboardFocusModule, + ReactiveFormsModule, + ], + declarations: [QuoteDetailsEditComponent], + exports: [QuoteDetailsEditComponent], +}) +export class QuoteDetailsEditModule {} diff --git a/feature-libs/quote/components/details/index.ts b/feature-libs/quote/components/details/index.ts index f509b955039..e353b2c90a5 100644 --- a/feature-libs/quote/components/details/index.ts +++ b/feature-libs/quote/components/details/index.ts @@ -5,6 +5,7 @@ */ export * from './cart'; +export * from './edit'; export * from './overview'; export * from './comment'; export * from './cart/summary'; diff --git a/feature-libs/quote/components/details/overview/quote-details-overview.component.html b/feature-libs/quote/components/details/overview/quote-details-overview.component.html index 4c06db184ff..49773c17d1f 100644 --- a/feature-libs/quote/components/details/overview/quote-details-overview.component.html +++ b/feature-libs/quote/components/details/overview/quote-details-overview.component.html @@ -1,78 +1,57 @@ -
-

+
+

{{ 'quote.commons.id' | cxTranslate }}: {{ quoteDetails.code }}

-

- {{ 'quote.commons.status' | cxTranslate }} +

+ {{ 'quote.commons.status' | cxTranslate }}: {{ 'quote.states.' + quoteDetails.state | cxTranslate }}

-
-
-
+
+
+ - - -
-
- - -
-
- - -
+ + + +
+
+ +
+
+
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 3102d22654a..91e01cea6a1 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 @@ -1,19 +1,24 @@ -import { Component } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; +import { Component, Input } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { - QuoteFacade, Quote, QuoteActionType, + QuoteFacade, + QuoteMetadata, QuoteState, } from '@spartacus/quote/root'; -import { I18nTestingModule, TranslationService } from '@spartacus/core'; -import { CardModule } from '@spartacus/storefront'; +import { + EventService, + I18nTestingModule, + TranslationService, +} from '@spartacus/core'; +import { CardModule, ICON_TYPE } from '@spartacus/storefront'; import { Observable, of } from 'rxjs'; import { QuoteDetailsOverviewComponent } from './quote-details-overview.component'; -import createSpy = jasmine.createSpy; +import { EditCard, SaveEvent } from '../edit/quote-details-edit.component'; +import { CommonQuoteTestUtilsService } from '../../testing/common-quote-test-utils.service'; const totalPriceFormattedValue = '$20'; @@ -21,6 +26,7 @@ const mockCartId = '1234'; const mockAction = { type: QuoteActionType.CREATE, isPrimary: true }; const mockQuote: Quote = { allowedActions: [mockAction], + isEditable: true, comments: [], cartId: mockCartId, code: '00001233', @@ -33,18 +39,31 @@ const mockQuote: Quote = { formattedValue: '$1.00', value: 1, }, - state: QuoteState.BUYER_ORDERED, + state: QuoteState.BUYER_DRAFT, name: 'Name', totalPrice: { value: 20, formattedValue: totalPriceFormattedValue }, - isEditable: true, }; -export class MockQuoteFacade implements Partial { - getQuoteDetails(): Observable { - return of(mockQuote); - } - setSort = createSpy(); - setCurrentPage = createSpy(); +@Component({ + selector: 'cx-quote-action-links', + template: '', +}) +export class MockQuoteActionLinksComponent {} + +@Component({ + selector: 'cx-icon', + template: '', +}) +class MockCxIconComponent { + @Input() type: ICON_TYPE; +} + +@Component({ + selector: 'cx-quote-details-edit', + template: '', +}) +class MockQuoteDetailsEditComponent { + @Input() content: EditCard | null; } class MockTranslationService implements Partial { @@ -53,99 +72,326 @@ class MockTranslationService implements Partial { } } -@Component({ - selector: 'cx-quote-action-links', - template: '', -}) -export class MockQuoteActionLinksComponent {} - describe('QuoteDetailsOverviewComponent', () => { let fixture: ComponentFixture; let component: QuoteDetailsOverviewComponent; + let htmlElem: HTMLElement; + let mockedQuoteFacade: QuoteFacade; + let mockedEventService: EventService; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [I18nTestingModule, CardModule, RouterTestingModule], - declarations: [ - QuoteDetailsOverviewComponent, - MockQuoteActionLinksComponent, - ], - providers: [ - { - provide: QuoteFacade, - useClass: MockQuoteFacade, - }, - { provide: TranslationService, useClass: MockTranslationService }, - ], - }).compileComponents(); - }); + beforeEach( + waitForAsync(() => { + initMocks(); + TestBed.configureTestingModule({ + imports: [I18nTestingModule, CardModule, RouterTestingModule], + declarations: [ + QuoteDetailsOverviewComponent, + MockCxIconComponent, + MockQuoteActionLinksComponent, + MockQuoteDetailsEditComponent, + ], + providers: [ + { + provide: QuoteFacade, + useValue: mockedQuoteFacade, + }, + { + provide: EventService, + useValue: mockedEventService, + }, + { provide: TranslationService, useClass: MockTranslationService }, + ], + }).compileComponents(); + }) + ); beforeEach(() => { fixture = TestBed.createComponent(QuoteDetailsOverviewComponent); + htmlElem = fixture.nativeElement; component = fixture.componentInstance; + + fixture.detectChanges(); }); + function initMocks() { + mockedQuoteFacade = jasmine.createSpyObj('quoteFacade', [ + 'getQuoteDetails', + 'editQuote', + ]); + asSpy(mockedQuoteFacade.getQuoteDetails).and.returnValue(of(mockQuote)); + asSpy(mockedQuoteFacade.editQuote).and.returnValue(of({})); + + mockedEventService = jasmine.createSpyObj('eventService', ['dispatch']); + } + + function asSpy(f: any) { + return f; + } + it('should create', () => { expect(component).toBeTruthy(); }); - it('should display overview if it is available', () => { - //when - fixture.detectChanges(); + describe('rendering', () => { + it('should render basic component framework accordingly', () => { + CommonQuoteTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-header-container' + ); + + CommonQuoteTestUtilsService.expectElementToContainText( + expect, + htmlElem, + '.cx-header', + 'quote.commons.id: ' + mockQuote.code + ); - //then - const quoteOverviewElement = fixture.debugElement.query( - By.css('.cx-quote-overview') - ); - expect(quoteOverviewElement.nativeElement.innerHTML).toBeDefined(); + CommonQuoteTestUtilsService.expectElementToContainText( + expect, + htmlElem, + '.cx-status', + 'quote.commons.status: quote.states.' + mockQuote.state + ); + + CommonQuoteTestUtilsService.expectElementPresent( + expect, + htmlElem, + '.cx-container' + ); + + CommonQuoteTestUtilsService.expectNumberOfElementsPresent( + expect, + htmlElem, + '.cx-summary-card', + 3 + ); + + CommonQuoteTestUtilsService.expectNumberOfElementsPresent( + expect, + htmlElem, + 'cx-card', + 3 + ); + }); + + it('should render component with deactivated edit mode', () => { + CommonQuoteTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'cx-card' + ); + + CommonQuoteTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'button.cx-edit-btn' + ); + + CommonQuoteTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'button.cx-action-link' + ); + + CommonQuoteTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'cx-icon' + ); + }); + + it('should render component with activated edit mode', () => { + component.toggleEditMode(); + fixture.detectChanges(); + + CommonQuoteTestUtilsService.expectElementPresent( + expect, + htmlElem, + 'cx-quote-details-edit' + ); + + CommonQuoteTestUtilsService.expectElementNotPresent( + expect, + htmlElem, + '.cx-edit-step' + ); + }); }); - it('should display titles and content in card if details are available', () => { - //when - fixture.detectChanges(); + describe('defineQuoteMetaData', () => { + it('should define an empty quote meta data object', () => { + const editEvent: SaveEvent = {}; + const metaData = component['defineQuoteMetaData'](editEvent); + expect(Object.keys(metaData).length).toBe(0); + }); + + it('should define a quote meta data object', () => { + const editEvent: SaveEvent = { + name: 'name', + description: 'description', + }; + const metaData = component['defineQuoteMetaData'](editEvent); - //then - const titleElements = fixture.debugElement.queryAll( - By.css('.cx-card-title') - ); - const contentElements = fixture.debugElement.queryAll( - By.css('.cx-card-label') - ); - const descriptionElement = fixture.debugElement.query( - By.css('.truncated-text') - ); - expect(titleElements.length).toEqual(7); - expect(contentElements.length).toEqual(6); - expect(descriptionElement.nativeElement.innerHTML).toBeDefined(); + expect(Object.keys(metaData).length).toBe(2); + expect(metaData.name).toBe(editEvent.name); + expect(metaData.description).toBe(editEvent.description); + }); }); - it('should return object with title and text if value is defined when getCardContent', () => { - //given - const value = 'test'; - const titleKey = 'key'; - const expected = { title: 'key', text: [value] }; + describe('isQuoteInformationEditable', () => { + let quote: Quote; + beforeEach(() => { + quote = structuredClone(mockQuote); + quote.state = QuoteState.SELLERAPPROVER_APPROVED; + }); - //then - component.getCardContent(value, titleKey).subscribe((result) => { - expect(result).toEqual(expected); + it('should return "false" if the quote information is not editable', () => { + quote.isEditable = false; + expect(component.isQuoteInformationEditable(quote)).toBe(false); + }); + + it('should return "false" if the quote information is not editable for "SELLER_DRAFT"', () => { + quote.state = QuoteState.SELLER_DRAFT; + expect(component.isQuoteInformationEditable(quote)).toBe(false); + }); + + it('should return "true" if the quote information is editable for "BUYER_DRAFT"', () => { + quote.state = QuoteState.BUYER_DRAFT; + expect(component.isQuoteInformationEditable(quote)).toBe(true); + }); + + it('should return "true" if the quote information is editable for "BUYER_OFFER"', () => { + quote.state = QuoteState.BUYER_OFFER; + expect(component.isQuoteInformationEditable(quote)).toBe(true); + }); + }); + + describe('handle actions', () => { + it('should handle cancel action', () => { + component.cancel(); + expect(component.editMode).toBe(false); + }); + + it('should handle edit action', () => { + const editEvent: SaveEvent = { + name: 'new name', + description: 'New Description', + }; + + const quoteMetaData: QuoteMetadata = { + name: editEvent.name, + description: editEvent.description, + }; + + component.save(mockQuote, editEvent); + expect(component.editMode).toBe(false); + expect(mockedQuoteFacade.editQuote).toHaveBeenCalledWith( + mockQuote.code, + quoteMetaData + ); }); }); - it('should return object with title and placeholder if value is not defined defined when getCardContent', () => { - //given - const value = null; - const titleKey = 'key'; - const expected = { title: 'key', text: ['-'] }; + it('should set edit mode to the opposite', () => { + expect(component.editMode).toBe(false); + component.toggleEditMode(); + expect(component.editMode).toBe(true); + }); + + describe('card content', () => { + it('should retrieve the card content that represents the quote information with empty name and description', () => { + fixture.detectChanges(); + + const expected = { + title: 'quote.details.information', + paragraphs: [ + { + title: 'quote.details.name', + text: ['-'], + }, + { + title: 'quote.details.description', + text: ['-'], + }, + ], + }; + + component + .getQuoteInformation(undefined, undefined) + .subscribe((result) => { + expect(result).toEqual(expected); + }); + }); + + it('should retrieve the edit card content that represents the edit quote information with its name and description', () => { + const name = 'Updated name'; + const description = 'Updated description'; - //then - component.getCardContent(value, titleKey).subscribe((result) => { + const expected = { + name: 'Updated name', + description: 'Updated description', + charactersLimit: 255, + }; + + const result = component.getEditQuoteInformation(name, description); expect(result).toEqual(expected); }); + + it('should the card content that represents an empty estimated and date information', () => { + fixture.detectChanges(); + + const expected = { + title: 'quote.details.estimateAndDate', + paragraphs: [ + { + title: 'quote.details.estimatedTotal', + text: [mockQuote.totalPrice.formattedValue], + }, + { + title: 'quote.details.created', + text: ['-'], + }, + ], + }; + + component + .getEstimatedAndDate(mockQuote, undefined) + .subscribe((result) => { + expect(result).toEqual(expected); + }); + }); + + it('should retrieve the card content that represents an empty update information', () => { + mockQuote.updatedTime = undefined; + mockQuote.expirationTime = undefined; + fixture.detectChanges(); + + const expected = { + title: 'quote.details.update', + paragraphs: [ + { + title: 'quote.details.lastUpdated', + text: ['-'], + }, + { + title: 'quote.details.expirationTime', + text: ['-'], + }, + ], + }; + + component.getUpdate(undefined, undefined).subscribe((result) => { + expect(result).toEqual(expected); + }); + }); }); describe('getTotalPrice', () => { it('should return the total price formatted value in case it is available', () => { - expect(component.getTotalPrice(mockQuote)).toBe(totalPriceFormattedValue); + expect(component['getTotalPrice'](mockQuote)).toBe( + totalPriceFormattedValue + ); }); it('should return null in case no formatted value is available', () => { @@ -153,13 +399,13 @@ describe('QuoteDetailsOverviewComponent', () => { ...mockQuote, totalPrice: {}, }; - expect(component.getTotalPrice(quoteWOPrices)).toBe(null); + expect(component['getTotalPrice'](quoteWOPrices)).toBe(null); }); }); describe('getTotalPriceDescription', () => { it('should name total price as estimated as long as final status not reached', () => { - expect(component.getTotalPriceDescription(mockQuote)).toBe( + expect(component['getTotalPriceDescription'](mockQuote)).toBe( 'quote.details.estimatedTotal' ); }); @@ -169,9 +415,16 @@ describe('QuoteDetailsOverviewComponent', () => { ...mockQuote, allowedActions: [{ type: QuoteActionType.CHECKOUT, isPrimary: true }], }; - expect(component.getTotalPriceDescription(quoteInOfferState)).toBe( + expect(component['getTotalPriceDescription'](quoteInOfferState)).toBe( 'quote.details.total' ); }); + + it('should be able to deal with empty actions', () => { + const quoteWoActions: Quote = { ...mockQuote, allowedActions: [] }; + 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 7cbde9be137..a34afa6f335 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 @@ -5,39 +5,213 @@ */ import { Component } from '@angular/core'; -import { Quote, QuoteActionType, QuoteFacade } from '@spartacus/quote/root'; -import { TranslationService } from '@spartacus/core'; -import { Card } from '@spartacus/storefront'; -import { Observable } from 'rxjs'; +import { + Quote, + QuoteAction, + QuoteActionType, + QuoteFacade, + QuoteMetadata, + QuoteState, +} from '@spartacus/quote/root'; +import { EventService, TranslationService } from '@spartacus/core'; +import { Card, ICON_TYPE } from '@spartacus/storefront'; +import { combineLatest, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { EditCard, SaveEvent } from '../edit/quote-details-edit.component'; @Component({ selector: 'cx-quote-details-overview', templateUrl: './quote-details-overview.component.html', }) export class QuoteDetailsOverviewComponent { + private static NO_DATA = '-'; + private static CHARACTERS_LIMIT = 255; + quoteDetails$: Observable = this.quoteFacade.getQuoteDetails(); + iconTypes = ICON_TYPE; + editMode = false; constructor( protected quoteFacade: QuoteFacade, + protected eventService: EventService, protected translationService: TranslationService ) {} - //TODO: consider to create similar generic function for all cx-card usages - getCardContent(value: string | null, titleKey: string): Observable { - return this.translationService.translate(titleKey).pipe( - map((title) => ({ - title, - text: [value ?? '-'], - })) + protected defineQuoteMetaData(event: SaveEvent): QuoteMetadata { + let metaData: QuoteMetadata = {}; + if (Object.getOwnPropertyNames(event).length >= 1) { + const [name, description] = [event.name, event.description]; + + metaData = { + ...metaData, + ...(event.name && { name }), + ...{ description }, + }; + } + return metaData; + } + + /** + * Verifies whether the quote information card tile is editable. + * + * @param {Quote} quote - quote + * @returns {boolean} - if the quote is editable and its state is 'QuoteState.BUYER_DRAFT' or 'QuoteState.BUYER_OFFER', otherwise returns 'false'. + */ + isQuoteInformationEditable(quote: Quote): boolean { + return ( + quote.isEditable && + (quote.state === QuoteState.BUYER_DRAFT || + quote.state === QuoteState.BUYER_OFFER) ); } + + /** + * Cancels the view of the edit card tile + * by setting the edit mode to 'false'. + */ + cancel() { + this.editMode = false; + } + + /** + * Saves the edited card tile. + * + * @param {Quote} quote - Quote + * @param {SaveEvent} event - edit event + */ + save(quote: Quote, event: SaveEvent) { + this.editMode = false; + const metaData: QuoteMetadata = this.defineQuoteMetaData(event); + + if (Object.getOwnPropertyNames(metaData).length >= 1) { + this.quoteFacade.editQuote(quote.code, metaData); + } + } + + /** + * Toggles the edit mode. + */ + toggleEditMode() { + this.editMode = !this.editMode; + } + + /** + * Retrieves the card content that represents the quote information with its name and description. + * + * @param {string} name - Quote name + * @param {string} description - Quote description + * @returns {Observable} - Card content + */ + getQuoteInformation(name?: string, description?: string): Observable { + return combineLatest([ + this.translationService.translate('quote.details.information'), + this.translationService.translate('quote.details.name'), + this.translationService.translate('quote.details.description'), + ]).pipe( + map(([infoTitle, nameTitle, descriptionTitle]) => { + return { + title: infoTitle, + paragraphs: [ + { + title: nameTitle, + text: [name ?? QuoteDetailsOverviewComponent.NO_DATA], + }, + { + title: descriptionTitle, + text: [description ?? QuoteDetailsOverviewComponent.NO_DATA], + }, + ], + }; + }) + ); + } + + /** + * Retrieves the edit card content that represents the edit quote information with its name and description. + * + * @param {string} name - Quote name + * @param {string} description - Quote description + * @returns {Observable} - Edit card content + */ + getEditQuoteInformation(name: string, description: string): EditCard { + return { + name: name, + description: description, + charactersLimit: QuoteDetailsOverviewComponent.CHARACTERS_LIMIT, + }; + } + + /** + * Retrieves the card content that represents the estimated and date information. + * + * @param {Quote} quote - Quote + * @param {any} createdDate - Created date + * @returns {Observable} - Card content + */ + getEstimatedAndDate(quote: Quote, createdDate?: string): Observable { + const totalPrice = + this.getTotalPrice(quote) ?? this.getTotalPriceDescription(quote); + return combineLatest([ + this.translationService.translate('quote.details.estimateAndDate'), + this.translationService.translate('quote.details.estimatedTotal'), + this.translationService.translate('quote.details.created'), + ]).pipe( + map(([firstTitle, secondTitle, thirdTitle]) => { + return { + title: firstTitle, + paragraphs: [ + { + title: secondTitle, + text: [totalPrice ?? QuoteDetailsOverviewComponent.NO_DATA], + }, + { + title: thirdTitle, + text: [createdDate ?? QuoteDetailsOverviewComponent.NO_DATA], + }, + ], + }; + }) + ); + } + + /** + * Retrieves the card content that represents the update information. + * + * @param {string} lastUpdated - last updated time + * @param {string} expirationTime - expiration time + * @returns {Observable} - Card content + */ + getUpdate(lastUpdated?: string, expirationTime?: string): Observable { + return combineLatest([ + this.translationService.translate('quote.details.update'), + this.translationService.translate('quote.details.lastUpdated'), + this.translationService.translate('quote.details.expirationTime'), + ]).pipe( + map(([firstTitle, secondTitle, thirdTitle]) => { + return { + title: firstTitle, + paragraphs: [ + { + title: secondTitle, + text: [lastUpdated ?? QuoteDetailsOverviewComponent.NO_DATA], + }, + { + title: thirdTitle, + text: [expirationTime ?? QuoteDetailsOverviewComponent.NO_DATA], + }, + ], + }; + }) + ); + } + /** * Returns total price as formatted string * @param quote Quote * @returns Total price formatted format, null if that is not available + * @protected */ - getTotalPrice(quote: Quote): string | null { + protected getTotalPrice(quote: Quote): string | null { return quote.totalPrice.formattedValue ?? null; } @@ -45,10 +219,11 @@ export class QuoteDetailsOverviewComponent { * Returns total price description * @param quote Quote * @returns 'Total' price if quote is in final state, 'Estimated total' otherwise + * @protected */ - getTotalPriceDescription(quote: Quote): string { - const readyToSubmit = quote.allowedActions.find( - (action) => action.type === QuoteActionType.CHECKOUT + protected getTotalPriceDescription(quote: Quote): string { + const readyToSubmit = quote.allowedActions?.find( + (action: QuoteAction) => action.type === QuoteActionType.CHECKOUT ); return readyToSubmit ? 'quote.details.total' diff --git a/feature-libs/quote/components/details/overview/quote-details-overview.module.ts b/feature-libs/quote/components/details/overview/quote-details-overview.module.ts index 616b4bf10fb..d76a146ddb5 100644 --- a/feature-libs/quote/components/details/overview/quote-details-overview.module.ts +++ b/feature-libs/quote/components/details/overview/quote-details-overview.module.ts @@ -12,11 +12,25 @@ import { I18nModule, provideDefaultConfig, } from '@spartacus/core'; -import { CardModule, SpinnerModule } from '@spartacus/storefront'; +import { + CardModule, + IconModule, + KeyboardFocusModule, + SpinnerModule, +} from '@spartacus/storefront'; import { QuoteDetailsOverviewComponent } from './quote-details-overview.component'; +import { QuoteDetailsEditModule } from '../edit/quote-details-edit.module'; @NgModule({ - imports: [CommonModule, I18nModule, CardModule, SpinnerModule], + imports: [ + CommonModule, + I18nModule, + IconModule, + CardModule, + QuoteDetailsEditModule, + KeyboardFocusModule, + SpinnerModule, + ], providers: [ provideDefaultConfig({ cmsComponents: { diff --git a/feature-libs/quote/components/quote-components.module.ts b/feature-libs/quote/components/quote-components.module.ts index d3ca1d44521..b5c23f9dd8c 100644 --- a/feature-libs/quote/components/quote-components.module.ts +++ b/feature-libs/quote/components/quote-components.module.ts @@ -15,6 +15,7 @@ import { QuoteRequestDialogModule } from './quote-request-dialog/quote-request-d import { QuoteConfirmActionDialogModule } from './quote-confirm-action-dialog/quote-confirm-action-dialog.module'; import { QuoteDetailsCartModule, + QuoteDetailsEditModule, QuoteDetailsOverviewModule, QuoteDetailsCommentModule, } from './details'; @@ -26,6 +27,7 @@ import { QuoteSellerEditModule } from './quote-seller-edit/quote-seller-edit.mod imports: [ CommonModule, QuoteListModule, + QuoteDetailsEditModule, QuoteDetailsOverviewModule, QuoteDetailsCartModule, QuoteRequestButtonModule, diff --git a/feature-libs/quote/components/testing/common-quote-test-utils.service.ts b/feature-libs/quote/components/testing/common-quote-test-utils.service.ts new file mode 100644 index 00000000000..68407ba3bf7 --- /dev/null +++ b/feature-libs/quote/components/testing/common-quote-test-utils.service.ts @@ -0,0 +1,127 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; + +/** + * Common configurator component test utils service provides helper functions for the component tests. + */ +export class CommonQuoteTestUtilsService { + /** + * Helper function for proving whether the element is present in the DOM tree. + * + * @param expect - Expectation for a spec. + * @param htmlElement - HTML element. + * @param querySelector - Query selector + */ + static expectElementPresent( + expect: any, + htmlElement: Element, + querySelector: string + ) { + expect(htmlElement.querySelectorAll(querySelector).length).toBeGreaterThan( + 0, + `expected element identified by selector '${querySelector}' to be present, but it is NOT! innerHtml: ${htmlElement.innerHTML}` + ); + } + + /** + * Helper function for proving whether the expected number of elements is present in the DOM tree. + * + * @param expect - Expectation for a spec. + * @param htmlElement - HTML element. + * @param querySelector - Query selector + * @param numberOfElements - Number of elements + */ + static expectNumberOfElementsPresent( + expect: any, + htmlElement: Element, + querySelector: string, + numberOfElements: number + ) { + expect(htmlElement.querySelectorAll(querySelector).length).toBe( + numberOfElements, + `expected elements identified by selector '${querySelector}' to be present, but it is NOT! innerHtml: ${htmlElement.innerHTML}` + ); + } + + /** + * Helper function for proving whether the element contains text. + * + * @param expect - Expectation for a spec. + * @param htmlElement - HTML element. + * @param querySelector - Query selector + * @param expectedText - Expected text + */ + static expectElementToContainText( + expect: any, + htmlElement: Element, + querySelector: string, + expectedText: string, + index?: number + ) { + let text; + if (index) { + text = htmlElement.querySelectorAll(querySelector)[index]?.textContent; + } else { + text = htmlElement.querySelector(querySelector)?.textContent; + } + expect(text ? text.trim() : '').toBe(expectedText); + } + + /** + * Helper function for proving whether the element is not present in the DOM tree. + * + * @param expect - Expectation for a spec. + * @param htmlElement - HTML element. + * @param querySelector - Query selector + */ + static expectElementNotPresent( + expect: any, + htmlElement: Element, + querySelector: string + ) { + expect(htmlElement.querySelectorAll(querySelector).length).toBe( + 0, + `expected element identified by selector '${querySelector}' to be NOT present, but it is! innerHtml: ${htmlElement.innerHTML}` + ); + } + + /** + * Helper function for proving how many times the element comes in the DOM tree. + * + * @param {any} expect - Expectation for a spec. + * @param {Element} htmlElement - HTML element. + * @param {string} querySelector - Query selector + * @param {number} expectedNumber- expected number of elements + */ + static expectNumberOfElements( + expect: any, + htmlElement: Element, + querySelector: string, + expectedNumber: number + ) { + expect(htmlElement.querySelectorAll(querySelector).length).toBe( + expectedNumber, + `expected elements identified by selector '${querySelector}' to be present times, but it is NOT! innerHtml: ${htmlElement.innerHTML}` + ); + } + + /** + * Retrieves a native element. + * + * @param {DebugElement} debugElement + * @param {string} querySelector + * @returns {HTMLElement} native element + */ + static getNativeElement( + debugElement: DebugElement, + querySelector: string + ): HTMLElement { + return debugElement.query(By.css(querySelector)).nativeElement; + } +} diff --git a/feature-libs/quote/styles/_index.scss b/feature-libs/quote/styles/_index.scss index 20921e3a512..a7c172a102c 100644 --- a/feature-libs/quote/styles/_index.scss +++ b/feature-libs/quote/styles/_index.scss @@ -1,6 +1,7 @@ @import './quote-list'; @import './quote-request-button'; @import './quote-request-dialog'; +@import './quote-details-edit'; @import './quote-details-overview'; @import './quote-details-cart'; @import './quote-action-links'; @@ -10,10 +11,10 @@ @import './quote-confirm-action-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-comment - cx-quote-seller-edit cx-quote-confirm-action-dialog !default; +$quote-components-allowlist: cx-quote-list cx-quote-details-edit + 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-comment cx-quote-seller-edit cx-quote-confirm-action-dialog !default; $skipComponentStyles: () !default; diff --git a/feature-libs/quote/styles/_quote-details-edit.scss b/feature-libs/quote/styles/_quote-details-edit.scss new file mode 100644 index 00000000000..968b5b42617 --- /dev/null +++ b/feature-libs/quote/styles/_quote-details-edit.scss @@ -0,0 +1,42 @@ +@import '@spartacus/styles/scss/components/misc/card/_card'; + +%cx-quote-details-edit { + .cx-card-paragraph { + textarea, + input { + width: 100%; + border: 1px solid var(--cx-color-medium); + } + + .cx-info-text { + font-size: var(--cx-font-size, 0.75rem); + font-style: italic; + text-align: end; + margin: 0; + } + } + + .cx-card-button-container { + display: flex; + flex-direction: row; + justify-content: flex-end; + padding-block-start: 1rem; + gap: 0.5rem; + + @include media-breakpoint-down(sm) { + flex-wrap: wrap; + } + + .btn { + @include media-breakpoint-up(sm) { + width: fit-content; + } + + @include media-breakpoint-down(sm) { + width: 100%; + } + } + } + + @extend %cx-card !optional; +} diff --git a/feature-libs/quote/styles/_quote-details-overview.scss b/feature-libs/quote/styles/_quote-details-overview.scss index 1221eadc333..ffb4223d6a1 100644 --- a/feature-libs/quote/styles/_quote-details-overview.scss +++ b/feature-libs/quote/styles/_quote-details-overview.scss @@ -1,54 +1,70 @@ -cx-quote-details-overview { - .cx-quote-overview { +@import '@spartacus/styles/scss/components/misc/card/_card'; + +%cx-quote-details-overview { + .cx-container { + display: flex; + flex-wrap: wrap; + padding: 15px 0; + gap: 1rem; + + @include media-breakpoint-down(md) { + max-width: 100%; + } + @include media-breakpoint-down(sm) { - background-color: var(--cx-color-inverse); + flex-direction: column; } - .container { - display: flex; - flex-wrap: wrap; - padding: 15px 0; - gap: 1rem; + .cx-summary-card { + position: relative; + flex: 1; + padding-inline-start: 15px; + padding-inline-end: 15px; + border: 1px solid var(--cx-color-medium); + border-radius: var(--cx-border-radius, 1rem); @include media-breakpoint-down(md) { - max-width: 100%; - padding: 0 1.25rem; + flex: 1; } + @include media-breakpoint-down(sm) { - flex-direction: column; - padding: 0 1.25rem; + flex: 1; + background-color: var(--cx-color-inverse); + border-width: 1px; + border-style: solid; + border-color: var(--cx-color-light); + margin: 0.625rem 0; } - .cx-summary-card { - flex: 1; - padding: 0 15px; - border: 1px solid var(--cx-color-medium); - border-radius: var(--cx-border-radius, 1rem); - - @include media-breakpoint-down(md) { - flex: 1; - } - - @include media-breakpoint-down(sm) { - flex: 1; - background-color: var(--cx-color-inverse); - border-width: 1px; - border-style: solid; - border-color: var(--cx-color-light); - margin: 0.625rem 0; - } - - .cx-card-title { - @include type('4'); - font-weight: bold; - margin-bottom: 0.5rem; - } - - .cx-card-description { - max-width: 100%; - word-break: break-word; - } + .cx-edit-btn { + position: absolute; + top: 10px; + right: 0px; + min-height: 5px; + min-width: 5px; + padding: 10px; + outline: none !important; + box-shadow: none !important; } + + .cx-card-title { + font-weight: var(--cx-font-weight-bold); + } + + .cx-card-paragraph-text { + font-weight: var(--cx-font-weight-bold); + } + + .cx-card-description { + max-width: 100%; + word-break: break-word; + } + + .cx-card-paragraph { + padding-inline-start: 0; + } + + @extend %cx-card !optional; } } } diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/quote.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/quote.ts index b093d1a0def..6917aeac9a6 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/quote.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/quote.ts @@ -183,7 +183,7 @@ export function checkQuoteInDraftState( } export function checkQuoteState(status: string) { - cy.get('cx-quote-details-overview h3.status').contains(status); + cy.get('cx-quote-details-overview h3.cx-status').contains(status); } /**