diff --git a/src/app/core/facades/checkout.facade.ts b/src/app/core/facades/checkout.facade.ts index db5686abe1..b39f883999 100644 --- a/src/app/core/facades/checkout.facade.ts +++ b/src/app/core/facades/checkout.facade.ts @@ -126,10 +126,10 @@ export class CheckoutFacade { } updateBasketItem(update: LineItemUpdate) { - if (update.quantity) { - this.store.dispatch(updateBasketItem({ lineItemUpdate: update })); - } else { + if (update.quantity === 0) { this.store.dispatch(deleteBasketItem({ itemId: update.itemId })); + } else { + this.store.dispatch(updateBasketItem({ lineItemUpdate: update })); } } diff --git a/src/app/core/store/customer/basket/basket-items.effects.ts b/src/app/core/store/customer/basket/basket-items.effects.ts index 5e47ad89a5..3630aeced3 100644 --- a/src/app/core/store/customer/basket/basket-items.effects.ts +++ b/src/app/core/store/customer/basket/basket-items.effects.ts @@ -196,7 +196,7 @@ export class BasketItemsEffects { itemUpdate.customFields = customFieldDefinitions.map(({ name, type }) => ({ name, type, - value: update.customFields[name], + value: update.customFields[name] || '', })); } diff --git a/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.html b/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.html index ebadbe65a1..8833afa324 100644 --- a/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.html +++ b/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.html @@ -1,8 +1,8 @@
-
- {{ - field.value || ('checkout.custom-field.no-value' | translate) - }} +
+ + + {{ field.value || ('checkout.custom-field.no-value' | translate) }} +
diff --git a/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.scss b/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.scss index ce83a0a35a..a55fe706cd 100644 --- a/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.scss +++ b/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.scss @@ -2,9 +2,11 @@ @import 'bootstrap/scss/functions'; .custom-fields-view { - display: flex; - flex-direction: column; - gap: divide($space-default, 3); + .custom-field { + &:last-of-type { + margin-bottom: divide($space-default * 2, 3); + } + } label { padding-right: divide($space-default, 3); diff --git a/src/app/shared/components/line-item/line-item-edit-dialog/line-item-edit-dialog.component.html b/src/app/shared/components/line-item/line-item-edit-dialog/line-item-edit-dialog.component.html new file mode 100644 index 0000000000..28c48b6b31 --- /dev/null +++ b/src/app/shared/components/line-item/line-item-edit-dialog/line-item-edit-dialog.component.html @@ -0,0 +1,37 @@ +
+ + +
+ +
+
+
+ + +
+ + + + +
{{ variationSalePrice$ | async | ishPrice }}
+ + + + + +
+ +
+ + +
+
+ +
+ +
+
+
+
+
+
diff --git a/src/app/shared/components/line-item/line-item-edit-dialog/line-item-edit-dialog.component.spec.ts b/src/app/shared/components/line-item/line-item-edit-dialog/line-item-edit-dialog.component.spec.ts new file mode 100644 index 0000000000..22387fa952 --- /dev/null +++ b/src/app/shared/components/line-item/line-item-edit-dialog/line-item-edit-dialog.component.spec.ts @@ -0,0 +1,86 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockComponent, MockPipe } from 'ng-mocks'; +import { EMPTY, of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; +import { PricePipe } from 'ish-core/models/price/price.pipe'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; +import { ProductCompletenessLevel } from 'ish-core/models/product/product.model'; +import { findAllCustomElements } from 'ish-core/utils/dev/html-query-utils'; +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; +import { ProductIdComponent } from 'ish-shared/components/product/product-id/product-id.component'; +import { ProductImageComponent } from 'ish-shared/components/product/product-image/product-image.component'; +import { ProductInventoryComponent } from 'ish-shared/components/product/product-inventory/product-inventory.component'; +import { ProductQuantityLabelComponent } from 'ish-shared/components/product/product-quantity-label/product-quantity-label.component'; +import { ProductQuantityComponent } from 'ish-shared/components/product/product-quantity/product-quantity.component'; +import { ProductVariationSelectComponent } from 'ish-shared/components/product/product-variation-select/product-variation-select.component'; + +import { LineItemEditDialogComponent } from './line-item-edit-dialog.component'; + +describe('Line Item Edit Dialog Component', () => { + let component: LineItemEditDialogComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let context: ProductContextFacade; + + beforeEach(async () => { + context = mock(ProductContextFacade); + + await TestBed.configureTestingModule({ + declarations: [ + LineItemEditDialogComponent, + MockComponent(LoadingComponent), + MockComponent(ProductIdComponent), + MockComponent(ProductImageComponent), + MockComponent(ProductInventoryComponent), + MockComponent(ProductQuantityComponent), + MockComponent(ProductQuantityLabelComponent), + MockComponent(ProductVariationSelectComponent), + MockPipe(PricePipe), + ], + providers: [{ provide: ProductContextFacade, useFactory: () => instance(context) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LineItemEditDialogComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + when(context.select('product')).thenReturn( + of({ + type: 'VariationProduct', + sku: 'SKU', + variableVariationAttributes: [], + available: true, + completenessLevel: ProductCompletenessLevel.List, + } as ProductView) + ); + when(context.select('prices')).thenReturn(EMPTY); + + when(context.select('loading')).thenReturn(of(false)); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should give correct product id of variation to product id component', () => { + fixture.detectChanges(); + expect(element.querySelector('ish-product-id')).toMatchInlineSnapshot(``); + }); + + it('should display ish-components on the container', () => { + fixture.detectChanges(); + expect(findAllCustomElements(element)).toIncludeAllMembers(['ish-product-quantity', 'ish-product-image']); + }); + + it('should display loading-components on the container', () => { + when(context.select('loading')).thenReturn(of(true)); + fixture.detectChanges(); + expect(findAllCustomElements(element)).toIncludeAllMembers(['ish-product-quantity', 'ish-loading']); + }); +}); diff --git a/src/app/shared/components/line-item/line-item-edit-dialog/line-item-edit-dialog.component.ts b/src/app/shared/components/line-item/line-item-edit-dialog/line-item-edit-dialog.component.ts new file mode 100644 index 0000000000..4394be83a8 --- /dev/null +++ b/src/app/shared/components/line-item/line-item-edit-dialog/line-item-edit-dialog.component.ts @@ -0,0 +1,29 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Observable, map } from 'rxjs'; + +import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; +import { Price } from 'ish-core/models/price/price.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; + +/** + * The Line Item Edit Dialog Component displays an edit-dialog of a line items to edit quantity and variation. + */ +@Component({ + selector: 'ish-line-item-edit-dialog', + templateUrl: './line-item-edit-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LineItemEditDialogComponent implements OnInit { + variation$: Observable; + variationSalePrice$: Observable; + loading$: Observable; + + constructor(private context: ProductContextFacade) {} + + ngOnInit() { + this.variation$ = this.context.select('product'); + this.variationSalePrice$ = this.context.select('prices').pipe(map(prices => prices?.salePrice)); + + this.loading$ = this.context.select('loading'); + } +} diff --git a/src/app/shared/components/line-item/line-item-edit/line-item-edit.component.html b/src/app/shared/components/line-item/line-item-edit/line-item-edit.component.html index 54cba9053c..d02ce27917 100644 --- a/src/app/shared/components/line-item/line-item-edit/line-item-edit.component.html +++ b/src/app/shared/components/line-item/line-item-edit/line-item-edit.component.html @@ -1,67 +1,24 @@ - + - - - - -
- - -
- -
-
-
- - -
- - - - -
{{ select('lineItem', 'singleBasePrice') | async | ishPrice }}
- - - - -
- - - - -
- - -
-
- -
- -
-
-
-
-
- - -
-
-
+ + + +
diff --git a/src/app/shared/components/line-item/line-item-edit/line-item-edit.component.spec.ts b/src/app/shared/components/line-item/line-item-edit/line-item-edit.component.spec.ts index 68c3f93e79..fda2cee7cc 100644 --- a/src/app/shared/components/line-item/line-item-edit/line-item-edit.component.spec.ts +++ b/src/app/shared/components/line-item/line-item-edit/line-item-edit.component.spec.ts @@ -1,12 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { MockComponent } from 'ng-mocks'; -import { EMPTY } from 'rxjs'; -import { anyString, instance, mock, when } from 'ts-mockito'; +import { instance, mock } from 'ts-mockito'; -import { AppFacade } from 'ish-core/facades/app.facade'; import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; -import { ModalDialogComponent } from 'ish-shared/components/common/modal-dialog/modal-dialog.component'; import { LineItemEditComponent } from './line-item-edit.component'; @@ -16,25 +11,10 @@ describe('Line Item Edit Component', () => { let element: HTMLElement; beforeEach(async () => { - const appFacade = mock(AppFacade); - when(appFacade.serverSetting$(anyString())).thenReturn(EMPTY); - when(appFacade.customFieldsForScope$(anyString())).thenReturn(EMPTY); - - const context = mock(ProductContextFacade); - when(context.select(anyString())).thenReturn(EMPTY); - when(context.select(anyString(), anyString())).thenReturn(EMPTY); - await TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [LineItemEditComponent, MockComponent(ModalDialogComponent)], - providers: [{ provide: AppFacade, useFactory: () => instance(appFacade) }], - }) - .overrideComponent(LineItemEditComponent, { - set: { - providers: [{ provide: ProductContextFacade, useFactory: () => instance(context) }], - }, - }) - .compileComponents(); + declarations: [LineItemEditComponent], + providers: [{ provide: ProductContextFacade, useFactory: () => instance(mock(ProductContextFacade)) }], + }).compileComponents(); }); beforeEach(() => { diff --git a/src/app/shared/components/line-item/line-item-edit/line-item-edit.component.ts b/src/app/shared/components/line-item/line-item-edit/line-item-edit.component.ts index 9d7d195152..56adf1b00b 100644 --- a/src/app/shared/components/line-item/line-item-edit/line-item-edit.component.ts +++ b/src/app/shared/components/line-item/line-item-edit/line-item-edit.component.ts @@ -1,34 +1,11 @@ -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, - EventEmitter, - Input, - OnInit, - Output, - Self, - ViewChild, -} from '@angular/core'; -import { UntypedFormGroup } from '@angular/forms'; -import { RxState } from '@rx-angular/state'; -import { combineLatest, take } from 'rxjs'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { Observable } from 'rxjs'; -import { AppFacade } from 'ish-core/facades/app.facade'; import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; -import { CustomFieldsComponentInput } from 'ish-core/models/custom-field/custom-field.model'; import { LineItemUpdate } from 'ish-core/models/line-item-update/line-item-update.model'; -import { LineItemView } from 'ish-core/models/line-item/line-item.model'; -import { ProductHelper } from 'ish-core/models/product/product.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; import { ModalDialogComponent } from 'ish-shared/components/common/modal-dialog/modal-dialog.component'; -interface ComponentState { - loading: boolean; - visible: boolean; - variationEditable: boolean; - lineItem: Partial>; - customFields: CustomFieldsComponentInput[]; -} - /** * The Line Item Edit Component displays an edit-link and edit-dialog. */ @@ -36,81 +13,34 @@ interface ComponentState { selector: 'ish-line-item-edit', templateUrl: './line-item-edit.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ProductContextFacade], }) -export class LineItemEditComponent extends RxState implements OnInit, AfterViewInit { +export class LineItemEditComponent implements OnInit { @ViewChild('modalDialog') modalDialogRef: ModalDialogComponent; - @Input({ required: true }) set lineItem(lineItem: ComponentState['lineItem']) { - this.set({ lineItem }); - this.resetContext(); - } - + @Input({ required: true }) itemId: string; @Output() updateItem = new EventEmitter(); - customFieldsForm = new UntypedFormGroup({}); + product$: Observable; - constructor(@Self() private context: ProductContextFacade, private appFacade: AppFacade) { - super(); - } + constructor(private context: ProductContextFacade) {} ngOnInit() { - this.connect( - 'variationEditable', - combineLatest([ - this.appFacade.serverSetting$('preferences.ChannelPreferences.EnableAdvancedVariationHandling'), - this.context.select('product'), - ]), - (_, [advancedVariationHandlingEnabled, product]) => - ProductHelper.isVariationProduct(product) && !advancedVariationHandlingEnabled - ); - - this.connect( - 'visible', - combineLatest([this.select('variationEditable'), this.appFacade.customFieldsForScope$('BasketLineItem')]), - (_, [variationEditable, customFields]) => variationEditable || customFields.length > 0 - ); - - this.connect('loading', this.context.select('loading')); - - this.connect( - 'customFields', - combineLatest([this.appFacade.customFieldsForScope$('BasketLineItem'), this.select('lineItem', 'customFields')]), - (_, [customFields, customFieldsData]) => - customFields.map(field => ({ - name: field.name, - editable: field.editable, - value: customFieldsData[field.name], - })) - ); + this.product$ = this.context.select('product'); } - ngAfterViewInit(): void { + show() { + this.modalDialogRef.show(); + this.context.hold(this.context.select('product'), product => { this.modalDialogRef.options.confirmDisabled = !product.available; - this.modalDialogRef.options.titleText = product.name; }); - } - - private resetContext() { - const lineItem = this.get('lineItem'); - this.context.set({ quantity: lineItem.quantity.value, sku: lineItem.productSKU }); - } - - show() { - this.resetContext(); - - this.context.hold(this.modalDialogRef.confirmed.pipe(take(1)), () => { - const customFields = this.customFieldsForm.value; + this.context.hold(this.modalDialogRef.confirmed, () => this.updateItem.emit({ - itemId: this.get('lineItem').id, + itemId: this.itemId, sku: this.context.get('sku'), quantity: this.context.get('quantity'), - customFields: Object.keys(customFields).length > 0 ? customFields : undefined, - }); - }); - - this.modalDialogRef.show(); + }) + ); } } diff --git a/src/app/shared/components/line-item/line-item-information-edit/line-item-information-edit.component.html b/src/app/shared/components/line-item/line-item-information-edit/line-item-information-edit.component.html new file mode 100644 index 0000000000..2f8adfc501 --- /dev/null +++ b/src/app/shared/components/line-item/line-item-information-edit/line-item-information-edit.component.html @@ -0,0 +1,71 @@ + + + +
+
+ + + +
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+
+
+
diff --git a/src/app/shared/components/line-item/line-item-information-edit/line-item-information-edit.component.spec.ts b/src/app/shared/components/line-item/line-item-information-edit/line-item-information-edit.component.spec.ts new file mode 100644 index 0000000000..fda2cee7cc --- /dev/null +++ b/src/app/shared/components/line-item/line-item-information-edit/line-item-information-edit.component.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { instance, mock } from 'ts-mockito'; + +import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; + +import { LineItemEditComponent } from './line-item-edit.component'; + +describe('Line Item Edit Component', () => { + let component: LineItemEditComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [LineItemEditComponent], + providers: [{ provide: ProductContextFacade, useFactory: () => instance(mock(ProductContextFacade)) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LineItemEditComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/shared/components/line-item/line-item-information-edit/line-item-information-edit.component.ts b/src/app/shared/components/line-item/line-item-information-edit/line-item-information-edit.component.ts new file mode 100644 index 0000000000..f0d9fe03f2 --- /dev/null +++ b/src/app/shared/components/line-item/line-item-information-edit/line-item-information-edit.component.ts @@ -0,0 +1,91 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, Self } from '@angular/core'; +import { UntypedFormGroup } from '@angular/forms'; +import { RxState } from '@rx-angular/state'; +import { combineLatest } from 'rxjs'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; +import { CustomFieldsComponentInput } from 'ish-core/models/custom-field/custom-field.model'; +import { LineItemUpdate } from 'ish-core/models/line-item-update/line-item-update.model'; +import { LineItemView } from 'ish-core/models/line-item/line-item.model'; + +interface ComponentState { + lineItem: Partial>; + loading: boolean; + visible: boolean; + customFields: CustomFieldsComponentInput[]; + editableFieldsMode: 'edit' | 'add'; // 'edit' for editable custom fields with existing values, else 'add' new values (relevant for translations) +} + +/** + * The Line Item Edit Component displays an edit-link and edit-dialog. + */ +@Component({ + selector: 'ish-line-item-information-edit', + templateUrl: './line-item-information-edit.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ProductContextFacade], +}) +export class LineItemInformationEditComponent extends RxState implements OnInit { + @Input({ required: true }) set lineItem(lineItem: ComponentState['lineItem']) { + this.set({ lineItem }); + this.resetContext(); + } + @Input() editable = true; + + @Output() updateItem = new EventEmitter(); + + customFieldsForm = new UntypedFormGroup({}); + editMode = false; + + constructor(@Self() private context: ProductContextFacade, private appFacade: AppFacade) { + super(); + } + + ngOnInit() { + this.connect( + 'visible', + this.appFacade.customFieldsForScope$('BasketLineItem'), + (_, customFields) => customFields.length > 0 + ); + + this.connect('loading', this.context.select('loading')); + + this.connect( + 'customFields', + combineLatest([this.appFacade.customFieldsForScope$('BasketLineItem'), this.select('lineItem', 'customFields')]), + (_, [customFields, customFieldsData]) => + customFields.map(field => ({ + name: field.name, + editable: field.editable, + value: customFieldsData[field.name], + })) + ); + + this.connect('editableFieldsMode', this.select('lineItem', 'customFields'), (_, customFieldsData) => + Object.keys(customFieldsData).length > 0 ? 'edit' : 'add' + ); + } + + private resetContext() { + const lineItem = this.get('lineItem'); + this.context.set({ sku: lineItem.productSKU }); + } + + save() { + const customFields = this.customFieldsForm.value; + + this.updateItem.emit({ + itemId: this.get('lineItem').id, + customFields: Object.keys(customFields).length > 0 ? customFields : undefined, + }); + } + cancel() { + this.editMode = false; + this.resetContext(); + } + + edit() { + this.editMode = !this.editMode; + } +} diff --git a/src/app/shared/components/line-item/line-item-list-element/line-item-list-element.component.html b/src/app/shared/components/line-item/line-item-list-element/line-item-list-element.component.html index 5acf363f96..056ffa53c9 100644 --- a/src/app/shared/components/line-item/line-item-list-element/line-item-list-element.component.html +++ b/src/app/shared/components/line-item/line-item-list-element/line-item-list-element.component.html @@ -20,8 +20,6 @@ - - @@ -39,6 +37,18 @@ + + + + + @@ -125,6 +135,12 @@
+
+
+ +
+
+
- - -
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index bb7370df16..b068957871 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -111,7 +111,9 @@ import { FilterNavigationComponent } from './components/filter/filter-navigation import { FilterSwatchImagesComponent } from './components/filter/filter-swatch-images/filter-swatch-images.component'; import { FilterTextComponent } from './components/filter/filter-text/filter-text.component'; import { LineItemCustomFieldsComponent } from './components/line-item/line-item-custom-fields/line-item-custom-fields.component'; +import { LineItemEditDialogComponent } from './components/line-item/line-item-edit-dialog/line-item-edit-dialog.component'; import { LineItemEditComponent } from './components/line-item/line-item-edit/line-item-edit.component'; +import { LineItemInformationEditComponent } from './components/line-item/line-item-information-edit/line-item-information-edit.component'; import { LineItemListElementComponent } from './components/line-item/line-item-list-element/line-item-list-element.component'; import { LineItemListComponent } from './components/line-item/line-item-list/line-item-list.component'; import { LineItemWarrantyComponent } from './components/line-item/line-item-warranty/line-item-warranty.component'; @@ -231,6 +233,8 @@ const declaredComponents = [ FilterSwatchImagesComponent, FilterTextComponent, LineItemEditComponent, + LineItemEditDialogComponent, + LineItemInformationEditComponent, LineItemListElementComponent, LineItemWarrantyComponent, LoginModalComponent, diff --git a/src/assets/i18n/en_US.json b/src/assets/i18n/en_US.json index 1e5f7c0cab..0dfaf206cb 100644 --- a/src/assets/i18n/en_US.json +++ b/src/assets/i18n/en_US.json @@ -712,10 +712,15 @@ "checkout.credit_card.user.name.missing.error": "Please enter a cardholder name.", "checkout.credit_card.validityTime.error.notFound": "The maximum validity time for CVC has not been found.", "checkout.custom-field.no-value": "No value", + "checkout.custom-fields.add.button.label": "Add", + "checkout.custom-fields.add.link.label": "Add product information", "checkout.custom-fields.basket.dialog.confirm": "OK", "checkout.custom-fields.basket.dialog.reject": "Cancel", "checkout.custom-fields.basket.dialog.title": "Custom Fields for Shopping Cart", + "checkout.custom-fields.cancel.button.label": "Cancel", "checkout.custom-fields.define-cta": "Click here to define additional Shopping Cart attributes", + "checkout.custom-fields.edit.link.label": "Edit product information", + "checkout.custom-fields.save.button.label": "Save changes", "checkout.desired_delivery_date.apply.button.label": "Apply", "checkout.desired_delivery_date.error.date": "The date is invalid. Please use the correct format ({{ translate, common.placeholder.shortdate-caps }}).", "checkout.desired_delivery_date.error.max_date": "Your desired delivery date cannot be realized because the date is too far in the future.",