diff --git a/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts b/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts index a30c4893bc..b162c73eab 100644 --- a/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts +++ b/projects/requisition-management/src/app/models/requisition/requisition.mapper.spec.ts @@ -97,6 +97,7 @@ describe('Requisition Mapper', () => { "commonShippingMethod": undefined, "costCenter": "CostCenter123", "creationDate": 12345678, + "customFields": {}, "customerNo": "OilCorp", "dynamicMessages": undefined, "email": "bboldner@test.intershop.de", diff --git a/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.html b/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.html index 7ceba33faf..64efa325a2 100644 --- a/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.html +++ b/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.html @@ -42,6 +42,8 @@

{{ 'approval.detailspage.order_details.heading' | translate }}

+ +
diff --git a/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.spec.ts b/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.spec.ts index e07c7c85e4..e9318be682 100644 --- a/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.spec.ts +++ b/projects/requisition-management/src/app/pages/requisition-detail/requisition-detail-page.component.spec.ts @@ -13,6 +13,7 @@ import { AddressComponent } from 'ish-shared/components/address/address/address. import { BasketCostSummaryComponent } from 'ish-shared/components/basket/basket-cost-summary/basket-cost-summary.component'; import { BasketMerchantMessageViewComponent } from 'ish-shared/components/basket/basket-merchant-message-view/basket-merchant-message-view.component'; import { BasketShippingMethodComponent } from 'ish-shared/components/basket/basket-shipping-method/basket-shipping-method.component'; +import { BasketCustomFieldsViewComponent } from 'ish-shared/components/checkout/basket-custom-fields-view/basket-custom-fields-view.component'; import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; import { InfoBoxComponent } from 'ish-shared/components/common/info-box/info-box.component'; @@ -38,6 +39,7 @@ describe('Requisition Detail Page Component', () => { declarations: [ MockComponent(AddressComponent), MockComponent(BasketCostSummaryComponent), + MockComponent(BasketCustomFieldsViewComponent), MockComponent(BasketMerchantMessageViewComponent), MockComponent(BasketShippingMethodComponent), MockComponent(ErrorMessageComponent), @@ -81,6 +83,7 @@ describe('Requisition Detail Page Component', () => { "ish-requisition-summary", "ish-requisition-cost-center-approval", "ish-basket-merchant-message-view", + "ish-basket-custom-fields-view", "ish-info-box", "ish-address", "ish-info-box", diff --git a/src/app/core/facades/app.facade.ts b/src/app/core/facades/app.facade.ts index 0c353cbc36..9b4eef3e54 100644 --- a/src/app/core/facades/app.facade.ts +++ b/src/app/core/facades/app.facade.ts @@ -5,6 +5,7 @@ import { Store, select } from '@ngrx/store'; import { combineLatest, merge, noop } from 'rxjs'; import { filter, map, sample, shareReplay, startWith, withLatestFrom } from 'rxjs/operators'; +import { CustomFieldDefinitionScopes } from 'ish-core/models/server-config/server-config.interface'; import { getAvailableLocales, getCurrentCurrency, @@ -15,7 +16,12 @@ import { } from 'ish-core/store/core/configuration'; import { businessError, getGeneralError, getGeneralErrorType } from 'ish-core/store/core/error'; import { selectPath } from 'ish-core/store/core/router'; -import { getExtraConfigParameter, getServerConfigParameter } from 'ish-core/store/core/server-config'; +import { + getCustomFieldDefinition, + getCustomFieldIdsForScope, + getExtraConfigParameter, + getServerConfigParameter, +} from 'ish-core/store/core/server-config'; import { getBreadcrumbData, getHeaderType, getWrapperClass, isStickyHeader } from 'ish-core/store/core/viewconf'; import { getLoggedInCustomer } from 'ish-core/store/customer/user'; import { getAllCountries, loadCountries } from 'ish-core/store/general/countries'; @@ -123,6 +129,14 @@ export class AppFacade { return this.store.pipe(select(getExtraConfigParameter(path))); } + customFieldsForScope$(scope: CustomFieldDefinitionScopes) { + return this.store.pipe(select(getCustomFieldIdsForScope(scope))); + } + + customField$(name: string) { + return this.store.pipe(select(getCustomFieldDefinition(name))); + } + /** * returns the currency symbol for the currency parameter in the current locale. * If no parameter is given, the the default currency is taken instead of it. diff --git a/src/app/core/facades/checkout.facade.ts b/src/app/core/facades/checkout.facade.ts index ef4f7b3cd6..b39f883999 100644 --- a/src/app/core/facades/checkout.facade.ts +++ b/src/app/core/facades/checkout.facade.ts @@ -7,6 +7,7 @@ import { debounceTime, distinctUntilChanged, map, sample, switchMap, take, tap } import { Address } from 'ish-core/models/address/address.model'; import { Attribute } from 'ish-core/models/attribute/attribute.model'; import { CheckoutStepType } from 'ish-core/models/checkout/checkout-step.type'; +import { CustomFields } from 'ish-core/models/custom-field/custom-field.model'; import { LineItemUpdate } from 'ish-core/models/line-item-update/line-item-update.model'; import { PaymentInstrument } from 'ish-core/models/payment-instrument/payment-instrument.model'; import { selectRouteData } from 'ish-core/store/core/router'; @@ -44,6 +45,7 @@ import { loadBasketWithId, removePromotionCodeFromBasket, setBasketAttribute, + setBasketCustomFields, setBasketDesiredDeliveryDate, setBasketPayment, startCheckout, @@ -124,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 })); } } @@ -160,6 +162,10 @@ export class CheckoutFacade { this.store.dispatch(addMessageToMerchant({ messageToMerchant: messageToMerchant || null })); } + setBasketCustomFields(customFields: CustomFields) { + this.store.dispatch(setBasketCustomFields({ customFields })); + } + // ORDERS private ordersError$ = this.store.pipe(select(getOrdersError)); diff --git a/src/app/core/models/basket/basket.interface.ts b/src/app/core/models/basket/basket.interface.ts index b90adbe20d..dfaddd788e 100644 --- a/src/app/core/models/basket/basket.interface.ts +++ b/src/app/core/models/basket/basket.interface.ts @@ -5,6 +5,7 @@ import { BasketInfo } from 'ish-core/models/basket-info/basket-info.model'; import { BasketRebateData } from 'ish-core/models/basket-rebate/basket-rebate.interface'; import { BasketTotalData } from 'ish-core/models/basket-total/basket-total.interface'; import { BasketWarrantyData } from 'ish-core/models/basket-warranty/basket-warranty.interface'; +import { CustomFieldData } from 'ish-core/models/custom-field/custom-field.interface'; import { LineItemData } from 'ish-core/models/line-item/line-item.interface'; import { PaymentInstrument } from 'ish-core/models/payment-instrument/payment-instrument.model'; import { PaymentMethodBaseData } from 'ish-core/models/payment-method/payment-method.interface'; @@ -55,6 +56,7 @@ export interface BasketBaseData { firstName: string; lastName: string; }; + customFields?: CustomFieldData[]; } export interface BasketIncludedData { diff --git a/src/app/core/models/basket/basket.mapper.ts b/src/app/core/models/basket/basket.mapper.ts index a044d8f264..8b7d6e7c06 100644 --- a/src/app/core/models/basket/basket.mapper.ts +++ b/src/app/core/models/basket/basket.mapper.ts @@ -3,6 +3,7 @@ import { BasketRebateData } from 'ish-core/models/basket-rebate/basket-rebate.in import { BasketRebateMapper } from 'ish-core/models/basket-rebate/basket-rebate.mapper'; import { BasketTotal } from 'ish-core/models/basket-total/basket-total.model'; import { BasketBaseData, BasketData } from 'ish-core/models/basket/basket.interface'; +import { CustomFieldMapper } from 'ish-core/models/custom-field/custom-field.mapper'; import { LineItemMapper } from 'ish-core/models/line-item/line-item.mapper'; import { PaymentMapper } from 'ish-core/models/payment/payment.mapper'; import { PriceItemMapper } from 'ish-core/models/price-item/price-item.mapper'; @@ -74,6 +75,7 @@ export class BasketMapper { user: data.buyer, externalOrderReference: data.externalOrderReference, messageToMerchant: data.messageToMerchant, + customFields: CustomFieldMapper.fromData(data.customFields), }; } diff --git a/src/app/core/models/basket/basket.model.ts b/src/app/core/models/basket/basket.model.ts index 550fb9c709..545aff4ad2 100644 --- a/src/app/core/models/basket/basket.model.ts +++ b/src/app/core/models/basket/basket.model.ts @@ -4,6 +4,7 @@ import { BasketApproval } from 'ish-core/models/basket-approval/basket-approval. import { BasketInfo } from 'ish-core/models/basket-info/basket-info.model'; import { BasketTotal } from 'ish-core/models/basket-total/basket-total.model'; import { BasketValidationResultType } from 'ish-core/models/basket-validation/basket-validation.model'; +import { CustomFields } from 'ish-core/models/custom-field/custom-field.model'; import { LineItem, LineItemView } from 'ish-core/models/line-item/line-item.model'; import { Payment } from 'ish-core/models/payment/payment.model'; import { ShippingMethod } from 'ish-core/models/shipping-method/shipping-method.model'; @@ -36,6 +37,7 @@ export interface AbstractBasket { }; externalOrderReference?: string; messageToMerchant?: string; + customFields?: CustomFields; } export type Basket = AbstractBasket; diff --git a/src/app/core/models/custom-field/custom-field.interface.ts b/src/app/core/models/custom-field/custom-field.interface.ts new file mode 100644 index 0000000000..1708f3929b --- /dev/null +++ b/src/app/core/models/custom-field/custom-field.interface.ts @@ -0,0 +1,5 @@ +import { Attribute } from 'ish-core/models/attribute/attribute.model'; + +export interface CustomFieldData extends Attribute { + type: 'String'; +} diff --git a/src/app/core/models/custom-field/custom-field.mapper.spec.ts b/src/app/core/models/custom-field/custom-field.mapper.spec.ts new file mode 100644 index 0000000000..02d0d174e4 --- /dev/null +++ b/src/app/core/models/custom-field/custom-field.mapper.spec.ts @@ -0,0 +1,59 @@ +import { CustomFieldData } from './custom-field.interface'; +import { CustomFieldMapper } from './custom-field.mapper'; + +describe('Custom Field Mapper', () => { + describe('fromData', () => { + it('should return empty object if no data supplied', () => { + const mapped = CustomFieldMapper.fromData(); + expect(mapped).toMatchInlineSnapshot(`{}`); + }); + + it('should return empty object if empty data supplied', () => { + const mapped = CustomFieldMapper.fromData([]); + expect(mapped).toMatchInlineSnapshot(`{}`); + }); + + it('should map incoming data to model data', () => { + const data: CustomFieldData[] = [ + { + name: 'foo', + value: 'foo', + type: 'String', + }, + { + name: 'bar', + value: 'bar', + type: 'String', + }, + ]; + const mapped = CustomFieldMapper.fromData(data); + expect(mapped).toMatchInlineSnapshot(` + { + "bar": "bar", + "foo": "foo", + } + `); + }); + + it('should filter empty data', () => { + const data: CustomFieldData[] = [ + { + name: 'foo', + value: '', + type: 'String', + }, + { + name: 'bar', + value: 'bar', + type: 'String', + }, + ]; + const mapped = CustomFieldMapper.fromData(data); + expect(mapped).toMatchInlineSnapshot(` + { + "bar": "bar", + } + `); + }); + }); +}); diff --git a/src/app/core/models/custom-field/custom-field.mapper.ts b/src/app/core/models/custom-field/custom-field.mapper.ts new file mode 100644 index 0000000000..17f4eb1811 --- /dev/null +++ b/src/app/core/models/custom-field/custom-field.mapper.ts @@ -0,0 +1,19 @@ +import { CustomFieldData } from './custom-field.interface'; +import { CustomFields } from './custom-field.model'; + +export class CustomFieldMapper { + static fromData(customFieldData: CustomFieldData[] = []): CustomFields { + return customFieldData.filter(CustomFieldMapper.hasValue).reduce((customFields, customField) => { + customFields[customField.name] = customField.value; + return customFields; + }, {}); + } + + private static hasValue(customFieldData: CustomFieldData): boolean { + if (customFieldData.type === 'String') { + return !!customFieldData.value; + } + // eslint-disable-next-line unicorn/no-null + return customFieldData.value !== null; + } +} diff --git a/src/app/core/models/custom-field/custom-field.model.ts b/src/app/core/models/custom-field/custom-field.model.ts new file mode 100644 index 0000000000..5a3890b55e --- /dev/null +++ b/src/app/core/models/custom-field/custom-field.model.ts @@ -0,0 +1,9 @@ +import { CustomFieldDefinitionScopeType } from 'ish-core/models/server-config/server-config.model'; + +type CustomFieldValue = string; + +export type CustomFields = Record; + +export type CustomFieldsComponentInput = CustomFieldDefinitionScopeType & { + value: CustomFieldValue; +}; diff --git a/src/app/core/models/line-item-update/line-item-update.model.ts b/src/app/core/models/line-item-update/line-item-update.model.ts index 043ec65184..83839c75df 100644 --- a/src/app/core/models/line-item-update/line-item-update.model.ts +++ b/src/app/core/models/line-item-update/line-item-update.model.ts @@ -1,7 +1,10 @@ +import { CustomFields } from 'ish-core/models/custom-field/custom-field.model'; + export interface LineItemUpdate { itemId: string; quantity?: number; sku?: string; unit?: string; + customFields?: CustomFields; warrantySku?: string; } diff --git a/src/app/core/models/line-item/line-item.interface.ts b/src/app/core/models/line-item/line-item.interface.ts index b8fa26cd4f..243b7bbafb 100644 --- a/src/app/core/models/line-item/line-item.interface.ts +++ b/src/app/core/models/line-item/line-item.interface.ts @@ -1,3 +1,4 @@ +import { CustomFieldData } from 'ish-core/models/custom-field/custom-field.interface'; import { PriceItemData } from 'ish-core/models/price-item/price-item.interface'; import { PriceData } from 'ish-core/models/price/price.interface'; @@ -35,5 +36,6 @@ export interface LineItemData { quantityFixed?: boolean; quote?: string; desiredDelivery?: string; + customFields?: CustomFieldData[]; warranty?: string; } diff --git a/src/app/core/models/line-item/line-item.mapper.ts b/src/app/core/models/line-item/line-item.mapper.ts index 70c38ca7f2..feb0660723 100644 --- a/src/app/core/models/line-item/line-item.mapper.ts +++ b/src/app/core/models/line-item/line-item.mapper.ts @@ -2,6 +2,7 @@ import { BasketRebateData } from 'ish-core/models/basket-rebate/basket-rebate.in import { BasketRebateMapper } from 'ish-core/models/basket-rebate/basket-rebate.mapper'; import { BasketWarrantyData } from 'ish-core/models/basket-warranty/basket-warranty.interface'; import { BasketWarrantyMapper } from 'ish-core/models/basket-warranty/basket-warranty.mapper'; +import { CustomFieldMapper } from 'ish-core/models/custom-field/custom-field.mapper'; import { OrderItemData } from 'ish-core/models/order-item/order-item.interface'; import { OrderLineItem } from 'ish-core/models/order/order.model'; import { PriceItemMapper } from 'ish-core/models/price-item/price-item.mapper'; @@ -56,6 +57,7 @@ export class LineItemMapper { editable: !data.quantityFixed, quote: data.quote ? data.quote : undefined, desiredDeliveryDate: data.desiredDelivery, + customFields: CustomFieldMapper.fromData(data.customFields), warranty: data.warranty && warrantyData ? BasketWarrantyMapper.fromData(warrantyData[data.warranty]) : undefined, }; diff --git a/src/app/core/models/line-item/line-item.model.ts b/src/app/core/models/line-item/line-item.model.ts index 7a189569aa..ccaaab4e8e 100644 --- a/src/app/core/models/line-item/line-item.model.ts +++ b/src/app/core/models/line-item/line-item.model.ts @@ -1,6 +1,7 @@ import { BasketFeedback } from 'ish-core/models/basket-feedback/basket-feedback.model'; import { BasketRebate } from 'ish-core/models/basket-rebate/basket-rebate.model'; import { BasketWarranty } from 'ish-core/models/basket-warranty/basket-warranty.model'; +import { CustomFields } from 'ish-core/models/custom-field/custom-field.model'; import { PriceItem } from 'ish-core/models/price-item/price-item.model'; import { Price } from 'ish-core/models/price/price.model'; import { SkuQuantityType } from 'ish-core/models/product/product.model'; @@ -38,7 +39,7 @@ export interface LineItem { editable: boolean; quote?: string; desiredDeliveryDate?: string; - + customFields?: CustomFields; warranty?: BasketWarranty; } diff --git a/src/app/core/models/order/order.mapper.ts b/src/app/core/models/order/order.mapper.ts index 17e58bf052..fcaf795d93 100644 --- a/src/app/core/models/order/order.mapper.ts +++ b/src/app/core/models/order/order.mapper.ts @@ -1,6 +1,7 @@ import { AddressMapper } from 'ish-core/models/address/address.mapper'; import { AttributeHelper } from 'ish-core/models/attribute/attribute.helper'; import { BasketMapper } from 'ish-core/models/basket/basket.mapper'; +import { CustomFieldMapper } from 'ish-core/models/custom-field/custom-field.mapper'; import { LineItemMapper } from 'ish-core/models/line-item/line-item.mapper'; import { PaymentMapper } from 'ish-core/models/payment/payment.mapper'; import { ShippingMethodMapper } from 'ish-core/models/shipping-method/shipping-method.mapper'; @@ -91,6 +92,7 @@ export class OrderMapper { user: data.buyer, messageToMerchant: data.messageToMerchant, externalOrderReference: data.externalOrderReference, + customFields: CustomFieldMapper.fromData(data.customFields), }; } } diff --git a/src/app/core/models/server-config/server-config.interface.ts b/src/app/core/models/server-config/server-config.interface.ts index 11c0d671d0..19ac49f5ab 100644 --- a/src/app/core/models/server-config/server-config.interface.ts +++ b/src/app/core/models/server-config/server-config.interface.ts @@ -1,5 +1,21 @@ +export type CustomFieldDefinitionScopes = 'Basket' | 'BasketLineItem' | 'Order' | 'OrderLineItem'; + +export interface CustomFieldDefinitionsData extends ServerConfigDataEntry { + description: string; + displayName: string; + name: string; + position: number; + type: 'String'; + scopes: { + isEditable: boolean; + isVisible: boolean; + name: CustomFieldDefinitionScopes; + }[]; +} + export interface ServerConfigDataEntry { - [key: string]: string | boolean | number | string[] | ServerConfigDataEntry; + customFieldDefinitions?: CustomFieldDefinitionsData[]; + [key: string]: string | boolean | number | string[] | ServerConfigDataEntry[] | ServerConfigDataEntry; } export interface ServerConfigData { diff --git a/src/app/core/models/server-config/server-config.mapper.spec.ts b/src/app/core/models/server-config/server-config.mapper.spec.ts index b539ec0f67..da3d033565 100644 --- a/src/app/core/models/server-config/server-config.mapper.spec.ts +++ b/src/app/core/models/server-config/server-config.mapper.spec.ts @@ -25,44 +25,243 @@ describe('Server Config Mapper', () => { }); expect(config).toMatchInlineSnapshot(` - { - "application": { - "applicationType": "intershop.B2CResponsive", - "displayName": null, - "urlIdentifier": "-", + [ + { + "application": { + "applicationType": "intershop.B2CResponsive", + "displayName": null, + "urlIdentifier": "-", + }, + "basket": { + "acceleration": true, + }, + "general": { + "locales": [ + "en_US", + "de_DE", + ], + }, + "services": { + "captcha": { + "siteKey": "ASDF", + }, + "deeper": { + "hidden": { + "alt": 123, + "foo": "bar", + "num": 123, + }, + }, + "gtm": { + "monitor": true, + "token": "QWERTY", + }, + }, }, - "basket": { - "acceleration": true, + { + "entities": {}, + "scopes": {}, }, - "general": { - "locales": [ - "en_US", - "de_DE", - ], + ] + `); + }); + + it(`should return an empty object for falsy input`, () => { + expect(ServerConfigMapper.fromData(undefined)).toMatchInlineSnapshot(` + [ + {}, + undefined, + ] + `); + expect(ServerConfigMapper.fromData({} as ServerConfigData)).toMatchInlineSnapshot(` + [ + {}, + undefined, + ] + `); + }); + + it('should map custom fields', () => { + const config = ServerConfigMapper.fromData({ + data: { + customFieldDefinitions: [ + { + description: 'foo', + displayName: 'foo', + name: 'foo', + position: 1, + scopes: [ + { + isEditable: true, + isVisible: true, + name: 'Basket', + }, + { + isEditable: true, + isVisible: true, + name: 'Order', + }, + ], + type: 'String', + }, + { + description: 'bar', + displayName: 'bar', + name: 'bar', + position: 2, + scopes: [ + { + isEditable: true, + isVisible: true, + name: 'BasketLineItem', + }, + ], + type: 'String', + }, + { + description: 'baz', + displayName: 'baz', + name: 'baz', + position: 3, + scopes: [ + { + isEditable: true, + isVisible: true, + name: 'Order', + }, + ], + type: 'String', + }, + { + description: 'qux', + displayName: 'qux', + name: 'qux', + position: 4, + scopes: [ + { + isEditable: true, + isVisible: true, + name: 'BasketLineItem', + }, + { + isEditable: false, + isVisible: false, + name: 'OrderLineItem', + }, + ], + type: 'String', + }, + { + description: 'wob', + displayName: 'wob', + name: 'wob', + position: 2, + scopes: [ + { + isEditable: true, + isVisible: true, + name: 'BasketLineItem', + }, + { + isEditable: false, + isVisible: true, + name: 'OrderLineItem', + }, + ], + type: 'String', + }, + ], + general: { + id: 'general', + locales: ['en_US', 'de_DE'], }, - "services": { - "captcha": { - "siteKey": "ASDF", + }, + }); + + expect(config).toMatchInlineSnapshot(` + [ + { + "general": { + "locales": [ + "en_US", + "de_DE", + ], }, - "deeper": { - "hidden": { - "alt": 123, - "foo": "bar", - "num": 123, + }, + { + "entities": { + "bar": { + "description": "bar", + "displayName": "bar", + "name": "bar", + "type": "String", + }, + "baz": { + "description": "baz", + "displayName": "baz", + "name": "baz", + "type": "String", + }, + "foo": { + "description": "foo", + "displayName": "foo", + "name": "foo", + "type": "String", + }, + "qux": { + "description": "qux", + "displayName": "qux", + "name": "qux", + "type": "String", + }, + "wob": { + "description": "wob", + "displayName": "wob", + "name": "wob", + "type": "String", }, }, - "gtm": { - "monitor": true, - "token": "QWERTY", + "scopes": { + "Basket": [ + { + "editable": true, + "name": "foo", + }, + ], + "BasketLineItem": [ + { + "editable": true, + "name": "bar", + }, + { + "editable": true, + "name": "wob", + }, + { + "editable": true, + "name": "qux", + }, + ], + "Order": [ + { + "editable": true, + "name": "foo", + }, + { + "editable": true, + "name": "baz", + }, + ], + "OrderLineItem": [ + { + "editable": false, + "name": "wob", + }, + ], }, }, - } + ] `); }); - - it(`should return an empty object for falsy input`, () => { - expect(ServerConfigMapper.fromData(undefined)).toMatchInlineSnapshot(`{}`); - expect(ServerConfigMapper.fromData({} as ServerConfigData)).toMatchInlineSnapshot(`{}`); - }); }); }); diff --git a/src/app/core/models/server-config/server-config.mapper.ts b/src/app/core/models/server-config/server-config.mapper.ts index 87bf40e034..43921b5d35 100644 --- a/src/app/core/models/server-config/server-config.mapper.ts +++ b/src/app/core/models/server-config/server-config.mapper.ts @@ -1,14 +1,16 @@ import { omit } from 'ish-core/utils/functions'; -import { ServerConfigData, ServerConfigDataEntry } from './server-config.interface'; -import { ServerConfig } from './server-config.model'; +import { CustomFieldDefinitionsData, ServerConfigData, ServerConfigDataEntry } from './server-config.interface'; +import { CustomFieldDefinitions, ServerConfig } from './server-config.model'; export class ServerConfigMapper { - static fromData(payload: ServerConfigData): ServerConfig { + static fromData(payload: ServerConfigData): [ServerConfig, CustomFieldDefinitions | undefined] { if (payload?.data) { - return ServerConfigMapper.mapEntries(payload.data); + const config = ServerConfigMapper.mapEntries(omit(payload.data, 'customFieldDefinitions')); + const definitions = ServerConfigMapper.mapCustomFields(payload.data.customFieldDefinitions); + return [config, definitions]; } - return {}; + return [{}, undefined]; } private static transformType(val: unknown) { @@ -42,4 +44,39 @@ export class ServerConfigMapper { {} ); } + + private static mapCustomFields(data: CustomFieldDefinitionsData[] = []): CustomFieldDefinitions { + const entities = data.reduce( + (acc, entry) => ({ + ...acc, + [entry.name]: { + description: entry.description, + displayName: entry.displayName, + name: entry.name, + type: entry.type, + }, + }), + {} + ); + + const scopes = data + .sort((a, b) => a.position - b.position) + .reduce((acc, entry) => { + entry.scopes.forEach(scope => { + if (!scope.isVisible) { + return; + } + if (!acc[scope.name]) { + acc[scope.name] = []; + } + acc[scope.name].push({ + name: entry.name, + editable: scope.isEditable, + }); + }); + return acc; + }, {}); + + return { entities, scopes }; + } } diff --git a/src/app/core/models/server-config/server-config.model.ts b/src/app/core/models/server-config/server-config.model.ts index 8877d2cd8a..33458f2214 100644 --- a/src/app/core/models/server-config/server-config.model.ts +++ b/src/app/core/models/server-config/server-config.model.ts @@ -1,7 +1,33 @@ +import { CustomFieldDefinitionScopes } from './server-config.interface'; + +export type CustomFieldDefinitionScopeType = { + name: string; + editable?: boolean; +}; + +interface Scopes { + [key: CustomFieldDefinitionScopes | string]: CustomFieldDefinitionScopeType[]; +} + +export type CustomFieldDefinition = { + description: string; + displayName: string; + name: string; + type: 'String'; +}; + +interface Entities { + [key: string]: CustomFieldDefinition; +} + +export interface CustomFieldDefinitions { + scopes: Scopes; + entities: Entities; +} + /** * model for config data (parameters, services etc.) retrieved from the ICM server. */ - export interface ServerConfig { [key: string]: string | boolean | string[] | ServerConfig; } diff --git a/src/app/core/services/basket-items/basket-items.service.ts b/src/app/core/services/basket-items/basket-items.service.ts index 780175b872..0398ff5edb 100644 --- a/src/app/core/services/basket-items/basket-items.service.ts +++ b/src/app/core/services/basket-items/basket-items.service.ts @@ -7,6 +7,7 @@ import { concatMap, first, map } from 'rxjs/operators'; import { BasketInfoMapper } from 'ish-core/models/basket-info/basket-info.mapper'; import { BasketInfo } from 'ish-core/models/basket-info/basket-info.model'; import { Basket } from 'ish-core/models/basket/basket.model'; +import { CustomFieldData } from 'ish-core/models/custom-field/custom-field.interface'; import { ErrorFeedback } from 'ish-core/models/http-error/http-error.model'; import { LineItemData } from 'ish-core/models/line-item/line-item.interface'; import { LineItemMapper } from 'ish-core/models/line-item/line-item.mapper'; @@ -14,12 +15,15 @@ import { AddLineItemType, LineItem, LineItemView } from 'ish-core/models/line-it import { ApiService } from 'ish-core/services/api/api.service'; import { getCurrentBasket } from 'ish-core/store/customer/basket'; -export type BasketItemUpdateType = - | { quantity?: { value: number; unit: string }; product?: string } - | { shippingMethod?: { id: string } } - | { desiredDelivery?: string } - | { calculated: boolean } - | { warranty?: string }; +export interface BasketItemUpdateType { + quantity?: { value: number; unit: string }; + product?: string; + shippingMethod?: { id: string }; + desiredDelivery?: string; + customFields?: CustomFieldData[]; + calculated?: boolean; + warranty?: string; +} /** * The Basket-Items Service handles basket line-item related calls for the 'baskets/items' REST API. diff --git a/src/app/core/services/basket/basket.service.ts b/src/app/core/services/basket/basket.service.ts index 854d549b26..8d92def94c 100644 --- a/src/app/core/services/basket/basket.service.ts +++ b/src/app/core/services/basket/basket.service.ts @@ -17,6 +17,7 @@ import { BasketValidation, BasketValidationScopeType } from 'ish-core/models/bas import { BasketBaseData, BasketData } from 'ish-core/models/basket/basket.interface'; import { BasketMapper } from 'ish-core/models/basket/basket.mapper'; import { Basket } from 'ish-core/models/basket/basket.model'; +import { CustomFieldData } from 'ish-core/models/custom-field/custom-field.interface'; import { ShippingMethodData } from 'ish-core/models/shipping-method/shipping-method.interface'; import { ShippingMethodMapper } from 'ish-core/models/shipping-method/shipping-method.mapper'; import { ShippingMethod } from 'ish-core/models/shipping-method/shipping-method.model'; @@ -31,7 +32,8 @@ export type BasketUpdateType = | { costCenter: string } | { externalOrderReference: string } | { invoiceToAddress: string } - | { messageToMerchant: string }; + | { messageToMerchant: string } + | { customFields: CustomFieldData[] }; /** * The Basket Service handles the interaction with the 'baskets' REST API. diff --git a/src/app/core/services/configuration/configuration.service.ts b/src/app/core/services/configuration/configuration.service.ts index 16cb71f3e5..099505668b 100644 --- a/src/app/core/services/configuration/configuration.service.ts +++ b/src/app/core/services/configuration/configuration.service.ts @@ -7,7 +7,7 @@ import { map } from 'rxjs/operators'; import { ContentConfigurationParameterMapper } from 'ish-core/models/content-configuration-parameter/content-configuration-parameter.mapper'; import { ContentPageletEntryPointData } from 'ish-core/models/content-pagelet-entry-point/content-pagelet-entry-point.interface'; import { ServerConfigMapper } from 'ish-core/models/server-config/server-config.mapper'; -import { ServerConfig } from 'ish-core/models/server-config/server-config.model'; +import { CustomFieldDefinitions, ServerConfig } from 'ish-core/models/server-config/server-config.model'; import { ApiService } from 'ish-core/services/api/api.service'; import { DomService } from 'ish-core/utils/dom/dom.service'; @@ -30,7 +30,7 @@ export class ConfigurationService { * * @returns The configuration object. */ - getServerConfiguration(): Observable { + getServerConfiguration(): Observable<[ServerConfig, CustomFieldDefinitions]> { return this.apiService .get(`configurations`, { headers: this.configHeaders, diff --git a/src/app/core/store/core/server-config/server-config.actions.ts b/src/app/core/store/core/server-config/server-config.actions.ts index df7d93df4d..ae9ecc0d5d 100644 --- a/src/app/core/store/core/server-config/server-config.actions.ts +++ b/src/app/core/store/core/server-config/server-config.actions.ts @@ -1,13 +1,13 @@ import { createAction } from '@ngrx/store'; -import { ServerConfig } from 'ish-core/models/server-config/server-config.model'; +import { CustomFieldDefinitions, ServerConfig } from 'ish-core/models/server-config/server-config.model'; import { httpError, payload } from 'ish-core/utils/ngrx-creators'; export const loadServerConfig = createAction('[Configuration Internal] Get the ICM configuration'); export const loadServerConfigSuccess = createAction( '[Configuration API] Get the ICM configuration Success', - payload<{ config: ServerConfig }>() + payload<{ config: ServerConfig; definitions?: CustomFieldDefinitions }>() ); export const loadServerConfigFail = createAction('[Configuration API] Get the ICM configuration Fail', httpError()); diff --git a/src/app/core/store/core/server-config/server-config.effects.spec.ts b/src/app/core/store/core/server-config/server-config.effects.spec.ts index 75bf2bbac5..59fbb94a10 100644 --- a/src/app/core/store/core/server-config/server-config.effects.spec.ts +++ b/src/app/core/store/core/server-config/server-config.effects.spec.ts @@ -30,7 +30,7 @@ describe('Server Config Effects', () => { const cookiesServiceMock = mock(CookiesService); const multiSiteServiceMock = mock(MultiSiteService); - when(configurationServiceMock.getServerConfiguration()).thenReturn(of({})); + when(configurationServiceMock.getServerConfiguration()).thenReturn(of([{}, undefined])); when(cookiesServiceMock.get(anything())).thenReturn('de_DE'); when(multiSiteServiceMock.getLangUpdatedUrl(anything(), anything())).thenReturn(of('/home;lang=de_DE')); when(multiSiteServiceMock.appendUrlParams(anything(), anything(), anything())).thenReturn('/home;lang=de_DE'); @@ -64,6 +64,7 @@ describe('Server Config Effects', () => { [Configuration Internal] Get the ICM configuration [Configuration API] Get the ICM configuration Success: config: {} + definitions: undefined `); }); }); @@ -123,7 +124,7 @@ describe('Server Config Effects', () => { describe('loadServerConfig$', () => { beforeEach(() => { - when(configurationServiceMock.getServerConfiguration()).thenReturn(of({})); + when(configurationServiceMock.getServerConfiguration()).thenReturn(of([{}, undefined])); }); it('should map to action of type LoadServerConfigSuccess', () => { diff --git a/src/app/core/store/core/server-config/server-config.effects.ts b/src/app/core/store/core/server-config/server-config.effects.ts index 45e3a50b5d..2bb3bf12ab 100644 --- a/src/app/core/store/core/server-config/server-config.effects.ts +++ b/src/app/core/store/core/server-config/server-config.effects.ts @@ -54,7 +54,7 @@ export class ServerConfigEffects { ofType(loadServerConfig), switchMap(() => this.configService.getServerConfiguration().pipe( - map(config => loadServerConfigSuccess({ config })), + map(([config, definitions]) => loadServerConfigSuccess({ config, definitions })), mapErrorToAction(loadServerConfigFail) ) ) diff --git a/src/app/core/store/core/server-config/server-config.reducer.ts b/src/app/core/store/core/server-config/server-config.reducer.ts index c9de2f3f66..eef016ba79 100644 --- a/src/app/core/store/core/server-config/server-config.reducer.ts +++ b/src/app/core/store/core/server-config/server-config.reducer.ts @@ -1,16 +1,18 @@ import { createReducer, on } from '@ngrx/store'; -import { ServerConfig } from 'ish-core/models/server-config/server-config.model'; +import { CustomFieldDefinitions, ServerConfig } from 'ish-core/models/server-config/server-config.model'; import { loadExtraConfigSuccess, loadServerConfigSuccess } from './server-config.actions'; export interface ServerConfigState { _config: ServerConfig; + _definitions: CustomFieldDefinitions; extra: ServerConfig; } const initialState: ServerConfigState = { _config: undefined, + _definitions: undefined, extra: undefined, }; @@ -21,6 +23,7 @@ export const serverConfigReducer = createReducer( (state, action): ServerConfigState => ({ ...state, _config: action.payload.config, + _definitions: action.payload.definitions, }) ), on( diff --git a/src/app/core/store/core/server-config/server-config.selectors.spec.ts b/src/app/core/store/core/server-config/server-config.selectors.spec.ts index 7672bc3a57..c6e27aa24b 100644 --- a/src/app/core/store/core/server-config/server-config.selectors.spec.ts +++ b/src/app/core/store/core/server-config/server-config.selectors.spec.ts @@ -1,10 +1,16 @@ import { TestBed } from '@angular/core/testing'; +import { isEqual } from 'lodash-es'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; import { StoreWithSnapshots, provideStoreSnapshots } from 'ish-core/utils/dev/ngrx-testing'; import { loadServerConfigSuccess } from './server-config.actions'; -import { getServerConfigParameter, isServerConfigurationLoaded } from './server-config.selectors'; +import { + getCustomFieldDefinition, + getCustomFieldIdsForScope, + getServerConfigParameter, + isServerConfigurationLoaded, +} from './server-config.selectors'; describe('Server Config Selectors', () => { let store$: StoreWithSnapshots; @@ -68,4 +74,103 @@ describe('Server Config Selectors', () => { `); }); }); + + describe('after setting config for custom fields', () => { + beforeEach(() => { + store$.dispatch( + loadServerConfigSuccess({ + config: {}, + definitions: { + entities: { + commissionNumber: { + name: 'commissionNumber', + description: 'Commission Number', + type: 'String', + displayName: 'Commission Number', + }, + projectNumber: { + name: 'projectNumber', + description: 'Project Number', + type: 'String', + displayName: 'Project Number', + }, + customerProductId: { + name: 'customerProductId', + description: 'Customer Product ID', + type: 'String', + displayName: 'Customer Product ID', + }, + }, + scopes: { + Basket: [ + { name: 'commissionNumber', editable: true }, + { name: 'projectNumber', editable: true }, + ], + Order: [ + { name: 'commissionNumber', editable: false }, + { name: 'projectNumber', editable: false }, + ], + BasketLineItem: [{ name: 'customerProductId', editable: true }], + OrderLineItem: [{ name: 'customerProductId', editable: false }], + }, + }, + }) + ); + }); + + it('should return custom field IDs definitions for scopes', () => { + expect.addSnapshotSerializer({ + test: val => + !Array.isArray(val) && + isEqual(Object.keys(val), ['name', 'editable']) && + typeof val.name === 'string' && + typeof val.editable === 'boolean', + print: (val: { name: string; editable: boolean }) => + `${val.name} (${val.editable ? 'editable' : 'not editable'})`, + }); + + expect(getCustomFieldIdsForScope('Basket')(store$.state)).toMatchInlineSnapshot(` + [ + commissionNumber (editable), + projectNumber (editable), + ] + `); + expect(getCustomFieldIdsForScope('BasketLineItem')(store$.state)).toMatchInlineSnapshot(` + [ + customerProductId (editable), + ] + `); + expect(getCustomFieldIdsForScope('Order')(store$.state)).toMatchInlineSnapshot(` + [ + commissionNumber (not editable), + projectNumber (not editable), + ] + `); + expect(getCustomFieldIdsForScope('OrderLineItem')(store$.state)).toMatchInlineSnapshot(` + [ + customerProductId (not editable), + ] + `); + }); + + it('should return the field for an ID', () => { + expect(getCustomFieldDefinition('commissionNumber')(store$.state)).toMatchInlineSnapshot(` + { + "description": "Commission Number", + "displayName": "Commission Number", + "name": "commissionNumber", + "type": "String", + } + `); + expect(getCustomFieldDefinition('customerProductId')(store$.state)).toMatchInlineSnapshot(` + { + "description": "Customer Product ID", + "displayName": "Customer Product ID", + "name": "customerProductId", + "type": "String", + } + `); + expect(getCustomFieldDefinition(undefined)(store$.state)).toBeUndefined(); + }); + }); }); diff --git a/src/app/core/store/core/server-config/server-config.selectors.ts b/src/app/core/store/core/server-config/server-config.selectors.ts index 9c97673abc..39efcb63a2 100644 --- a/src/app/core/store/core/server-config/server-config.selectors.ts +++ b/src/app/core/store/core/server-config/server-config.selectors.ts @@ -1,5 +1,6 @@ import { createSelector } from '@ngrx/store'; +import { CustomFieldDefinitionScopes } from 'ish-core/models/server-config/server-config.interface'; import { getCoreState } from 'ish-core/store/core/core-store'; const getServerConfigState = createSelector(getCoreState, state => state.serverConfig); @@ -29,3 +30,16 @@ export const getExtraConfigParameter = (path: string) => .split('.') .reduce((obj, key) => (obj?.[key] !== undefined ? obj[key] : undefined), extraConfig) as unknown as T ); + +const getCustomFieldDefinitions = createSelector(getServerConfigState, state => state._definitions); + +export const getCustomFieldIdsForScope = (scope: CustomFieldDefinitionScopes) => + createSelector(getCustomFieldDefinitions, customFieldDefinitions => customFieldDefinitions?.scopes?.[scope] ?? []); + +export const getCustomFieldsForScope = (scope: CustomFieldDefinitionScopes) => + createSelector(getCustomFieldDefinitions, getCustomFieldIdsForScope(scope), (customFieldDefinitions, ids) => + ids.map(({ name, editable }) => ({ name, editable, ...customFieldDefinitions?.entities?.[name] })) + ); + +export const getCustomFieldDefinition = (name: string) => + createSelector(getCustomFieldDefinitions, customFieldDefinitions => customFieldDefinitions?.entities?.[name]); diff --git a/src/app/core/store/customer/basket/basket-items.effects.spec.ts b/src/app/core/store/customer/basket/basket-items.effects.spec.ts index 386fbb6992..d60dfd23c4 100644 --- a/src/app/core/store/customer/basket/basket-items.effects.spec.ts +++ b/src/app/core/store/customer/basket/basket-items.effects.spec.ts @@ -13,6 +13,7 @@ import { Product } from 'ish-core/models/product/product.model'; import { BasketItemsService } from 'ish-core/services/basket-items/basket-items.service'; import { BasketService } from 'ish-core/services/basket/basket.service'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; +import { loadServerConfigSuccess } from 'ish-core/store/core/server-config'; import { CustomerStoreModule } from 'ish-core/store/customer/customer-store.module'; import { loadProduct, loadProductSuccess } from 'ish-core/store/shopping/products'; import { ShoppingStoreModule } from 'ish-core/store/shopping/shopping-store.module'; @@ -52,7 +53,7 @@ describe('Basket Items Effects', () => { TestBed.configureTestingModule({ imports: [ - CoreStoreModule.forTesting(), + CoreStoreModule.forTesting(['serverConfig']), CustomerStoreModule.forTesting('basket'), RouterTestingModule.withRoutes([{ path: '**', children: [] }]), ShoppingStoreModule.forTesting('products', 'categories'), @@ -68,6 +69,8 @@ describe('Basket Items Effects', () => { effects = TestBed.inject(BasketItemsEffects); store = TestBed.inject(Store); location = TestBed.inject(Location); + + store.dispatch(loadServerConfigSuccess({ config: {} })); }); describe('addProductToBasket$', () => { 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 778d982ac5..3630aeced3 100644 --- a/src/app/core/store/customer/basket/basket-items.effects.ts +++ b/src/app/core/store/customer/basket/basket-items.effects.ts @@ -6,8 +6,10 @@ import { from } from 'rxjs'; import { concatMap, debounceTime, filter, map, mergeMap, switchMap, toArray, window } from 'rxjs/operators'; import { LineItemUpdate } from 'ish-core/models/line-item-update/line-item-update.model'; +import { CustomFieldDefinitionsData } from 'ish-core/models/server-config/server-config.interface'; import { BasketItemUpdateType, BasketItemsService } from 'ish-core/services/basket-items/basket-items.service'; import { BasketService } from 'ish-core/services/basket/basket.service'; +import { getCustomFieldsForScope } from 'ish-core/store/core/server-config'; import { getProductEntities, loadProduct } from 'ish-core/store/shopping/products'; import { mapErrorToAction, mapToPayload, mapToPayloadProperty } from 'ish-core/utils/operators'; @@ -103,11 +105,14 @@ export class BasketItemsEffects { mapToPayloadProperty('lineItemUpdate'), concatLatestFrom(() => this.store.pipe(select(getCurrentBasket))), filter(([payload, basket]) => !!basket.lineItems && !!payload), - concatMap(([lineItem]) => - this.basketItemsService.updateBasketItem(lineItem.itemId, this.determineUpdateItemPayload(lineItem)).pipe( - map(payload => updateBasketItemSuccess(payload)), - mapErrorToAction(updateBasketItemFail) - ) + concatLatestFrom(() => this.store.pipe(select(getCustomFieldsForScope('BasketLineItem')))), + concatMap(([[lineItem], customFieldDefinitions]) => + this.basketItemsService + .updateBasketItem(lineItem.itemId, this.mapLineItemUpdate(lineItem, customFieldDefinitions)) + .pipe( + map(payload => updateBasketItemSuccess(payload)), + mapErrorToAction(updateBasketItemFail) + ) ) ) ); @@ -176,16 +181,30 @@ export class BasketItemsEffects { { dispatch: false } ); - private determineUpdateItemPayload(lineItem: LineItemUpdate): BasketItemUpdateType { - const payload: BasketItemUpdateType = { - quantity: lineItem.quantity > 0 ? { value: lineItem.quantity, unit: lineItem.unit } : undefined, - product: lineItem.sku, + private mapLineItemUpdate( + update: LineItemUpdate, + customFieldDefinitions: { name: string; type: CustomFieldDefinitionsData['type'] }[] + ): BasketItemUpdateType { + const itemUpdate: Partial = { + product: update.sku, }; - if (lineItem.warrantySku || lineItem.warrantySku === '') { + if (update.quantity > 0) { + itemUpdate.quantity = { value: update.quantity, unit: update.unit }; + } + if (update.customFields) { + itemUpdate.customFields = customFieldDefinitions.map(({ name, type }) => ({ + name, + type, + value: update.customFields[name] || '', + })); + } + + if (update.warrantySku || update.warrantySku === '') { // eslint-disable-next-line unicorn/no-null - return { ...payload, warranty: lineItem.warrantySku ? lineItem.warrantySku : null }; + itemUpdate.warranty = update.warrantySku ? update.warrantySku : null; } // undefined is not working here - return payload; + + return itemUpdate; } } diff --git a/src/app/core/store/customer/basket/basket.actions.ts b/src/app/core/store/customer/basket/basket.actions.ts index ab4fbe5c20..b6573c76f7 100644 --- a/src/app/core/store/customer/basket/basket.actions.ts +++ b/src/app/core/store/customer/basket/basket.actions.ts @@ -7,6 +7,7 @@ import { BasketInfo } from 'ish-core/models/basket-info/basket-info.model'; import { BasketValidation, BasketValidationScopeType } from 'ish-core/models/basket-validation/basket-validation.model'; import { Basket } from 'ish-core/models/basket/basket.model'; import { CheckoutStepType } from 'ish-core/models/checkout/checkout-step.type'; +import { CustomFields } from 'ish-core/models/custom-field/custom-field.model'; import { ErrorFeedback } from 'ish-core/models/http-error/http-error.model'; import { LineItemUpdate } from 'ish-core/models/line-item-update/line-item-update.model'; import { AddLineItemType, LineItem } from 'ish-core/models/line-item/line-item.model'; @@ -68,10 +69,17 @@ export const updateBasketCostCenter = createAction( '[Basket] Assign a Cost Center at Basket ', payload<{ costCenter: string }>() ); + export const addMessageToMerchant = createAction( '[Basket] Message to Merchant', payload<{ messageToMerchant: string }>() ); + +export const setBasketCustomFields = createAction( + '[Basket] Set Basket Custom Fields', + payload<{ customFields: CustomFields }>() +); + export const updateBasket = createAction('[Basket] Update Basket', payload<{ update: BasketUpdateType }>()); export const updateBasketFail = createAction('[Basket API] Update Basket Fail', httpError()); diff --git a/src/app/core/store/customer/basket/basket.effects.ts b/src/app/core/store/customer/basket/basket.effects.ts index f9d8a5a206..1170a6396b 100644 --- a/src/app/core/store/customer/basket/basket.effects.ts +++ b/src/app/core/store/customer/basket/basket.effects.ts @@ -23,6 +23,7 @@ import { BasketItemsService } from 'ish-core/services/basket-items/basket-items. import { BasketService } from 'ish-core/services/basket/basket.service'; import { getCurrentCurrency } from 'ish-core/store/core/configuration'; import { mapToRouterState } from 'ish-core/store/core/router'; +import { getCustomFieldsForScope } from 'ish-core/store/core/server-config'; import { resetOrderErrors } from 'ish-core/store/customer/orders'; import { getLoggedInCustomer, loginUserSuccess, personalizationStatusDetermined } from 'ish-core/store/customer/user'; import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; @@ -53,6 +54,7 @@ import { setBasketAttribute, setBasketAttributeFail, setBasketAttributeSuccess, + setBasketCustomFields, setBasketDesiredDeliveryDate, setBasketDesiredDeliveryDateFail, setBasketDesiredDeliveryDateSuccess, @@ -222,6 +224,22 @@ export class BasketEffects { ) ); + setBasketCustomFields$ = createEffect(() => + this.actions$.pipe( + ofType(setBasketCustomFields), + mapToPayloadProperty('customFields'), + concatLatestFrom(() => this.store.pipe(select(getCustomFieldsForScope('Basket')))), + map(([customFields, definitions]) => + definitions.map(definition => ({ + name: definition.name, + value: customFields[definition.name], + type: definition.type, + })) + ), + map(customFields => updateBasket({ update: { customFields } })) + ) + ); + /** * Sets a desired delivery date at the current basket and each line item. */ diff --git a/src/app/pages/account-order/account-order/account-order.component.html b/src/app/pages/account-order/account-order/account-order.component.html index 9d2187fd6c..184bbc8a90 100644 --- a/src/app/pages/account-order/account-order/account-order.component.html +++ b/src/app/pages/account-order/account-order/account-order.component.html @@ -62,6 +62,8 @@

{{ 'account.orderdetails.heading.default' | translate }}

+ +
diff --git a/src/app/pages/account-order/account-order/account-order.component.spec.ts b/src/app/pages/account-order/account-order/account-order.component.spec.ts index d67d16f0f6..f736c827c2 100644 --- a/src/app/pages/account-order/account-order/account-order.component.spec.ts +++ b/src/app/pages/account-order/account-order/account-order.component.spec.ts @@ -11,6 +11,7 @@ import { AddressComponent } from 'ish-shared/components/address/address/address. import { BasketCostSummaryComponent } from 'ish-shared/components/basket/basket-cost-summary/basket-cost-summary.component'; import { BasketMerchantMessageViewComponent } from 'ish-shared/components/basket/basket-merchant-message-view/basket-merchant-message-view.component'; import { BasketShippingMethodComponent } from 'ish-shared/components/basket/basket-shipping-method/basket-shipping-method.component'; +import { BasketCustomFieldsViewComponent } from 'ish-shared/components/checkout/basket-custom-fields-view/basket-custom-fields-view.component'; import { InfoBoxComponent } from 'ish-shared/components/common/info-box/info-box.component'; import { LineItemListComponent } from 'ish-shared/components/line-item/line-item-list/line-item-list.component'; @@ -31,6 +32,7 @@ describe('Account Order Component', () => { MockComponent(AccountOrderToBasketComponent), MockComponent(AddressComponent), MockComponent(BasketCostSummaryComponent), + MockComponent(BasketCustomFieldsViewComponent), MockComponent(BasketMerchantMessageViewComponent), MockComponent(BasketShippingMethodComponent), MockComponent(FaIconComponent), diff --git a/src/app/pages/basket/shopping-basket/shopping-basket.component.html b/src/app/pages/basket/shopping-basket/shopping-basket.component.html index 71730e379f..8e94a772d7 100644 --- a/src/app/pages/basket/shopping-basket/shopping-basket.component.html +++ b/src/app/pages/basket/shopping-basket/shopping-basket.component.html @@ -45,6 +45,9 @@

+ + +
diff --git a/src/app/pages/basket/shopping-basket/shopping-basket.component.spec.ts b/src/app/pages/basket/shopping-basket/shopping-basket.component.spec.ts index 662ae4be37..f88f7efe5a 100644 --- a/src/app/pages/basket/shopping-basket/shopping-basket.component.spec.ts +++ b/src/app/pages/basket/shopping-basket/shopping-basket.component.spec.ts @@ -10,6 +10,7 @@ import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; import { ContentIncludeComponent } from 'ish-shared/cms/components/content-include/content-include.component'; import { BasketCostCenterSelectionComponent } from 'ish-shared/components/basket/basket-cost-center-selection/basket-cost-center-selection.component'; import { BasketCostSummaryComponent } from 'ish-shared/components/basket/basket-cost-summary/basket-cost-summary.component'; +import { BasketCustomFieldsComponent } from 'ish-shared/components/basket/basket-custom-fields/basket-custom-fields.component'; import { BasketErrorMessageComponent } from 'ish-shared/components/basket/basket-error-message/basket-error-message.component'; import { BasketInfoComponent } from 'ish-shared/components/basket/basket-info/basket-info.component'; import { BasketPromotionCodeComponent } from 'ish-shared/components/basket/basket-promotion-code/basket-promotion-code.component'; @@ -35,6 +36,7 @@ describe('Shopping Basket Component', () => { declarations: [ MockComponent(BasketCostCenterSelectionComponent), MockComponent(BasketCostSummaryComponent), + MockComponent(BasketCustomFieldsComponent), MockComponent(BasketErrorMessageComponent), MockComponent(BasketInfoComponent), MockComponent(BasketPromotionCodeComponent), diff --git a/src/app/pages/checkout-receipt/checkout-receipt/checkout-receipt.component.html b/src/app/pages/checkout-receipt/checkout-receipt/checkout-receipt.component.html index 5c187abd30..93b60930e6 100644 --- a/src/app/pages/checkout-receipt/checkout-receipt/checkout-receipt.component.html +++ b/src/app/pages/checkout-receipt/checkout-receipt/checkout-receipt.component.html @@ -15,6 +15,8 @@
+ +
diff --git a/src/app/pages/checkout-receipt/checkout-receipt/checkout-receipt.component.spec.ts b/src/app/pages/checkout-receipt/checkout-receipt/checkout-receipt.component.spec.ts index eb8c66f005..a77686f863 100644 --- a/src/app/pages/checkout-receipt/checkout-receipt/checkout-receipt.component.spec.ts +++ b/src/app/pages/checkout-receipt/checkout-receipt/checkout-receipt.component.spec.ts @@ -11,6 +11,7 @@ import { AddressComponent } from 'ish-shared/components/address/address/address. import { BasketCostSummaryComponent } from 'ish-shared/components/basket/basket-cost-summary/basket-cost-summary.component'; import { BasketMerchantMessageViewComponent } from 'ish-shared/components/basket/basket-merchant-message-view/basket-merchant-message-view.component'; import { BasketShippingMethodComponent } from 'ish-shared/components/basket/basket-shipping-method/basket-shipping-method.component'; +import { BasketCustomFieldsViewComponent } from 'ish-shared/components/checkout/basket-custom-fields-view/basket-custom-fields-view.component'; import { InfoBoxComponent } from 'ish-shared/components/common/info-box/info-box.component'; import { LineItemListComponent } from 'ish-shared/components/line-item/line-item-list/line-item-list.component'; @@ -27,6 +28,7 @@ describe('Checkout Receipt Component', () => { CheckoutReceiptComponent, MockComponent(AddressComponent), MockComponent(BasketCostSummaryComponent), + MockComponent(BasketCustomFieldsViewComponent), MockComponent(BasketMerchantMessageViewComponent), MockComponent(BasketShippingMethodComponent), MockComponent(FaIconComponent), @@ -66,6 +68,7 @@ describe('Checkout Receipt Component', () => { expect(findAllCustomElements(element)).toMatchInlineSnapshot(` [ "ish-basket-merchant-message-view", + "ish-basket-custom-fields-view", "ish-info-box", "ish-address", "ish-info-box", diff --git a/src/app/pages/checkout-review/checkout-review/checkout-review.component.html b/src/app/pages/checkout-review/checkout-review/checkout-review.component.html index 9bbf8e0547..af0c14dcf0 100644 --- a/src/app/pages/checkout-review/checkout-review/checkout-review.component.html +++ b/src/app/pages/checkout-review/checkout-review/checkout-review.component.html @@ -44,6 +44,9 @@

+ + +
{ MockComponent(AddressComponent), MockComponent(BasketApprovalInfoComponent), MockComponent(BasketCostSummaryComponent), + MockComponent(BasketCustomFieldsViewComponent), MockComponent(BasketMerchantMessageViewComponent), MockComponent(BasketShippingMethodComponent), MockComponent(BasketValidationResultsComponent), @@ -115,6 +117,7 @@ describe('Checkout Review Component', () => { "ish-error-message", "ish-basket-validation-results", "ish-basket-merchant-message-view", + "ish-basket-custom-fields-view", "ish-info-box", "ish-address", "ish-info-box", diff --git a/src/app/shared/components/basket/basket-custom-fields/basket-custom-fields.component.html b/src/app/shared/components/basket/basket-custom-fields/basket-custom-fields.component.html new file mode 100644 index 0000000000..7f0fb809a3 --- /dev/null +++ b/src/app/shared/components/basket/basket-custom-fields/basket-custom-fields.component.html @@ -0,0 +1,24 @@ + +
+ + +
+ + +
+ + +
+
diff --git a/src/app/shared/components/basket/basket-custom-fields/basket-custom-fields.component.spec.ts b/src/app/shared/components/basket/basket-custom-fields/basket-custom-fields.component.spec.ts new file mode 100644 index 0000000000..9874c63b25 --- /dev/null +++ b/src/app/shared/components/basket/basket-custom-fields/basket-custom-fields.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockComponent } from 'ng-mocks'; +import { EMPTY } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; +import { CustomFieldsFormlyComponent } from 'ish-shared/components/custom-fields/custom-fields-formly/custom-fields-formly.component'; +import { CustomFieldsViewComponent } from 'ish-shared/components/custom-fields/custom-fields-view/custom-fields-view.component'; + +import { BasketCustomFieldsComponent } from './basket-custom-fields.component'; + +describe('Basket Custom Fields Component', () => { + let component: BasketCustomFieldsComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + const checkoutFacade = mock(CheckoutFacade); + when(checkoutFacade.basket$).thenReturn(EMPTY); + + await TestBed.configureTestingModule({ + declarations: [ + BasketCustomFieldsComponent, + MockComponent(CustomFieldsFormlyComponent), + MockComponent(CustomFieldsViewComponent), + ], + providers: [ + { provide: AppFacade, useFactory: () => instance(mock(AppFacade)) }, + { provide: CheckoutFacade, useFactory: () => instance(checkoutFacade) }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BasketCustomFieldsComponent); + 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/basket/basket-custom-fields/basket-custom-fields.component.ts b/src/app/shared/components/basket/basket-custom-fields/basket-custom-fields.component.ts new file mode 100644 index 0000000000..3f7d74d2b5 --- /dev/null +++ b/src/app/shared/components/basket/basket-custom-fields/basket-custom-fields.component.ts @@ -0,0 +1,47 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { UntypedFormGroup } from '@angular/forms'; +import { Observable, combineLatest, debounce, map } from 'rxjs'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; +import { CustomFieldsComponentInput } from 'ish-core/models/custom-field/custom-field.model'; +import { whenFalsy } from 'ish-core/utils/operators'; + +@Component({ + selector: 'ish-basket-custom-fields', + templateUrl: './basket-custom-fields.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BasketCustomFieldsComponent implements OnInit { + customFields$: Observable; + + visible$: Observable; + noValues$: Observable; + + constructor(private appFacade: AppFacade, private checkoutFacade: CheckoutFacade) {} + + form = new UntypedFormGroup({}); + + ngOnInit(): void { + this.customFields$ = combineLatest([ + this.appFacade.customFieldsForScope$('Basket'), + this.checkoutFacade.basket$.pipe(debounce(() => this.checkoutFacade.basketLoading$.pipe(whenFalsy()))), + ]).pipe( + map(([customFields, basket]) => + customFields.map(customField => ({ ...customField, value: basket.customFields[customField.name] })) + ) + ); + + this.visible$ = this.customFields$.pipe(map(fields => fields.length > 0)); + + this.noValues$ = this.customFields$.pipe(map(fields => fields.length > 0 && fields.every(field => !field.value))); + } + + submit() { + this.checkoutFacade.setBasketCustomFields(this.form.value); + } + + submitDisabled(): boolean { + return !this.form.valid || this.form.pristine; + } +} diff --git a/src/app/shared/components/checkout/basket-custom-fields-view/basket-custom-fields-view.component.html b/src/app/shared/components/checkout/basket-custom-fields-view/basket-custom-fields-view.component.html new file mode 100644 index 0000000000..8e785add8d --- /dev/null +++ b/src/app/shared/components/checkout/basket-custom-fields-view/basket-custom-fields-view.component.html @@ -0,0 +1,7 @@ + + +

+ +

+
+
diff --git a/src/app/shared/components/checkout/basket-custom-fields-view/basket-custom-fields-view.component.spec.ts b/src/app/shared/components/checkout/basket-custom-fields-view/basket-custom-fields-view.component.spec.ts new file mode 100644 index 0000000000..15836ae1f8 --- /dev/null +++ b/src/app/shared/components/checkout/basket-custom-fields-view/basket-custom-fields-view.component.spec.ts @@ -0,0 +1,35 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { EMPTY } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { AppFacade } from 'ish-core/facades/app.facade'; + +import { BasketCustomFieldsViewComponent } from './basket-custom-fields-view.component'; + +describe('Basket Custom Fields View Component', () => { + let component: BasketCustomFieldsViewComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + const appFacade = mock(AppFacade); + when(appFacade.customFieldsForScope$('Basket')).thenReturn(EMPTY); + + await TestBed.configureTestingModule({ + declarations: [BasketCustomFieldsViewComponent], + providers: [{ provide: AppFacade, useFactory: () => instance(appFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BasketCustomFieldsViewComponent); + 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/checkout/basket-custom-fields-view/basket-custom-fields-view.component.ts b/src/app/shared/components/checkout/basket-custom-fields-view/basket-custom-fields-view.component.ts new file mode 100644 index 0000000000..111e559a06 --- /dev/null +++ b/src/app/shared/components/checkout/basket-custom-fields-view/basket-custom-fields-view.component.ts @@ -0,0 +1,37 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable, ReplaySubject, combineLatest, map } from 'rxjs'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { CustomFields, CustomFieldsComponentInput } from 'ish-core/models/custom-field/custom-field.model'; + +@Component({ + selector: 'ish-basket-custom-fields-view', + templateUrl: './basket-custom-fields-view.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BasketCustomFieldsViewComponent implements OnInit { + @Input() set data(val: { customFields?: CustomFields }) { + this._customFields.next(val.customFields || {}); + } + @Input() editRouterLink: string; + + private _customFields = new ReplaySubject(1); + + fields$: Observable; + visible$: Observable; + + constructor(private appFacade: AppFacade) {} + + ngOnInit(): void { + this.fields$ = combineLatest([ + this._customFields.asObservable(), + this.appFacade.customFieldsForScope$('Basket'), + ]).pipe( + map(([customFields, customFieldsForScope]) => + customFieldsForScope.map(({ name, editable }) => ({ name, value: customFields[name], editable })) + ) + ); + + this.visible$ = this.fields$.pipe(map(fields => fields.some(field => field.value))); + } +} diff --git a/src/app/shared/components/custom-fields/custom-fields-formly/custom-fields-formly.component.html b/src/app/shared/components/custom-fields/custom-fields-formly/custom-fields-formly.component.html new file mode 100644 index 0000000000..c052397beb --- /dev/null +++ b/src/app/shared/components/custom-fields/custom-fields-formly/custom-fields-formly.component.html @@ -0,0 +1 @@ + diff --git a/src/app/shared/components/custom-fields/custom-fields-formly/custom-fields-formly.component.spec.ts b/src/app/shared/components/custom-fields/custom-fields-formly/custom-fields-formly.component.spec.ts new file mode 100644 index 0000000000..3231160dcb --- /dev/null +++ b/src/app/shared/components/custom-fields/custom-fields-formly/custom-fields-formly.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { instance, mock } from 'ts-mockito'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { FormlyTestingModule } from 'ish-shared/formly/dev/testing/formly-testing.module'; + +import { CustomFieldsFormlyComponent } from './custom-fields-formly.component'; + +describe('Custom Fields Formly Component', () => { + let component: CustomFieldsFormlyComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FormlyTestingModule], + declarations: [CustomFieldsFormlyComponent], + providers: [{ provide: AppFacade, useFactory: () => instance(mock(AppFacade)) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CustomFieldsFormlyComponent); + 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/custom-fields/custom-fields-formly/custom-fields-formly.component.ts b/src/app/shared/components/custom-fields/custom-fields-formly/custom-fields-formly.component.ts new file mode 100644 index 0000000000..883b43b3c4 --- /dev/null +++ b/src/app/shared/components/custom-fields/custom-fields-formly/custom-fields-formly.component.ts @@ -0,0 +1,75 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { UntypedFormGroup } from '@angular/forms'; +import { FormlyFieldConfig } from '@ngx-formly/core'; +import { RxState } from '@rx-angular/state'; +import { Observable, combineLatest, map, switchMap } from 'rxjs'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { CustomFields, CustomFieldsComponentInput } from 'ish-core/models/custom-field/custom-field.model'; + +interface ComponentState { + form: UntypedFormGroup; + customFields: CustomFieldsComponentInput[]; + fields: FormlyFieldConfig[]; + model: CustomFields; +} + +@Component({ + selector: 'ish-custom-fields-formly', + templateUrl: './custom-fields-formly.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CustomFieldsFormlyComponent extends RxState implements OnInit { + @Input() set form(form: ComponentState['form']) { + this.set({ form }); + } + @Input() set fields(customFields: ComponentState['customFields']) { + this.set({ customFields }); + } + @Input() labelClass: string; + @Input() fieldClass: string; + + fields$: Observable; + model$: Observable; + form$: Observable; + + constructor(private appFacade: AppFacade) { + super(); + } + + ngOnInit(): void { + this.form$ = this.select('form'); + + this.connect( + 'fields', + this.select('customFields').pipe( + switchMap(fields => + combineLatest( + fields.map(field => + this.appFacade.customField$(field.name).pipe( + map(definition => ({ + key: field.name, + type: 'ish-text-input-field', + templateOptions: { + label: definition.displayName, + labelClass: this.labelClass, + fieldClass: this.fieldClass, + }, + })) + ) + ) + ) + ) + ) + ); + this.fields$ = this.select('fields'); + + this.connect( + 'model', + this.select('customFields').pipe( + map(fields => fields.reduce((acc, field) => ({ ...acc, [field.name]: field.value }), {})) + ) + ); + this.model$ = this.select('model'); + } +} 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 new file mode 100644 index 0000000000..8833afa324 --- /dev/null +++ b/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.html @@ -0,0 +1,8 @@ +
+
+ + + {{ 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 new file mode 100644 index 0000000000..a55fe706cd --- /dev/null +++ b/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.scss @@ -0,0 +1,24 @@ +@import 'variables'; +@import 'bootstrap/scss/functions'; + +.custom-fields-view { + .custom-field { + &:last-of-type { + margin-bottom: divide($space-default * 2, 3); + } + } + + label { + padding-right: divide($space-default, 3); + margin: 0; + + &::after { + content: ':'; + } + } + + span.no-value { + font-style: italic; + color: $text-color-quaternary; + } +} diff --git a/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.spec.ts b/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.spec.ts new file mode 100644 index 0000000000..6092b7683a --- /dev/null +++ b/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { instance, mock } from 'ts-mockito'; + +import { AppFacade } from 'ish-core/facades/app.facade'; + +import { CustomFieldsViewComponent } from './custom-fields-view.component'; + +describe('Custom Fields View Component', () => { + let component: CustomFieldsViewComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [CustomFieldsViewComponent], + providers: [{ provide: AppFacade, useFactory: () => instance(mock(AppFacade)) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CustomFieldsViewComponent); + 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/custom-fields/custom-fields-view/custom-fields-view.component.ts b/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.ts new file mode 100644 index 0000000000..6a8c8ef137 --- /dev/null +++ b/src/app/shared/components/custom-fields/custom-fields-view/custom-fields-view.component.ts @@ -0,0 +1,39 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable, ReplaySubject, combineLatest, map, switchMap } from 'rxjs'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { CustomFieldsComponentInput } from 'ish-core/models/custom-field/custom-field.model'; +import { CustomFieldDefinition } from 'ish-core/models/server-config/server-config.model'; + +@Component({ + selector: 'ish-custom-fields-view', + templateUrl: './custom-fields-view.component.html', + styleUrls: ['./custom-fields-view.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CustomFieldsViewComponent implements OnInit { + @Input() showEmpty = false; + + @Input() + set fields(val: CustomFieldsComponentInput[]) { + this.fields$.next(val); + } + + private fields$ = new ReplaySubject(1); + + data$: Observable<(CustomFieldsComponentInput & Pick)[]>; + + constructor(private appFacade: AppFacade) {} + + ngOnInit(): void { + this.data$ = this.fields$.pipe( + switchMap(fields => + combineLatest( + fields + .filter(field => this.showEmpty || field.value !== undefined) + .map(field => this.appFacade.customField$(field.name).pipe(map(def => ({ ...def, ...field })))) + ) + ) + ); + } +} diff --git a/src/app/shared/components/line-item/line-item-custom-fields/line-item-custom-fields.component.html b/src/app/shared/components/line-item/line-item-custom-fields/line-item-custom-fields.component.html new file mode 100644 index 0000000000..b4ce8744a6 --- /dev/null +++ b/src/app/shared/components/line-item/line-item-custom-fields/line-item-custom-fields.component.html @@ -0,0 +1 @@ + diff --git a/src/app/shared/components/line-item/line-item-custom-fields/line-item-custom-fields.component.spec.ts b/src/app/shared/components/line-item/line-item-custom-fields/line-item-custom-fields.component.spec.ts new file mode 100644 index 0000000000..4c0eac2856 --- /dev/null +++ b/src/app/shared/components/line-item/line-item-custom-fields/line-item-custom-fields.component.spec.ts @@ -0,0 +1,37 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { CustomFieldsViewComponent } from 'ish-shared/components/custom-fields/custom-fields-view/custom-fields-view.component'; + +import { LineItemCustomFieldsComponent } from './line-item-custom-fields.component'; + +describe('Line Item Custom Fields Component', () => { + let component: LineItemCustomFieldsComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + const appFacade = mock(AppFacade); + when(appFacade.customFieldsForScope$('BasketLineItem')).thenReturn(of([])); + + await TestBed.configureTestingModule({ + declarations: [LineItemCustomFieldsComponent, MockComponent(CustomFieldsViewComponent)], + providers: [{ provide: AppFacade, useFactory: () => instance(appFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LineItemCustomFieldsComponent); + 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-custom-fields/line-item-custom-fields.component.ts b/src/app/shared/components/line-item/line-item-custom-fields/line-item-custom-fields.component.ts new file mode 100644 index 0000000000..d434d7f21d --- /dev/null +++ b/src/app/shared/components/line-item/line-item-custom-fields/line-item-custom-fields.component.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { RxState } from '@rx-angular/state'; +import { combineLatest, map } from 'rxjs'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { CustomFields, CustomFieldsComponentInput } from 'ish-core/models/custom-field/custom-field.model'; + +@Component({ + selector: 'ish-line-item-custom-fields', + templateUrl: './line-item-custom-fields.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LineItemCustomFieldsComponent + extends RxState<{ + fields: CustomFieldsComponentInput[]; + customFields: CustomFields; + editable: boolean; + }> + implements OnInit +{ + @Input() set lineItem(val: { customFields?: CustomFields }) { + this.set('customFields', () => val.customFields || {}); + } + @Input() set editable(val: boolean) { + this.set('editable', () => val); + } + + constructor(private appFacade: AppFacade) { + super(); + } + + ngOnInit(): void { + this.connect( + 'fields', + combineLatest([this.select('customFields'), this.appFacade.customFieldsForScope$('BasketLineItem')]).pipe( + map(([customFields, definitions]) => + definitions.map(({ name, editable }) => ({ + name, + editable, + value: customFields[name], + })) + ) + ) + ); + } +} 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 d754b51c30..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 @@ -79,24 +79,6 @@
-
- - - -
@@ -132,6 +114,7 @@ {{ oldPrice | ishPrice }} +
@@ -146,6 +129,37 @@
{{ 'checkout.pli.freegift.text' | translate }}
+ + + +
+
+
+ +
+
+ +
+
+ + + +
+
+
diff --git a/src/app/shared/components/line-item/line-item-list-element/line-item-list-element.component.spec.ts b/src/app/shared/components/line-item/line-item-list-element/line-item-list-element.component.spec.ts index 86dfeb71f0..4396d2bb97 100644 --- a/src/app/shared/components/line-item/line-item-list-element/line-item-list-element.component.spec.ts +++ b/src/app/shared/components/line-item/line-item-list-element/line-item-list-element.component.spec.ts @@ -11,9 +11,9 @@ import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; 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 { ServerSettingPipe } from 'ish-core/pipes/server-setting.pipe'; import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; import { findAllCustomElements } from 'ish-core/utils/dev/html-query-utils'; +import { LineItemCustomFieldsComponent } from 'ish-shared/components/line-item/line-item-custom-fields/line-item-custom-fields.component'; import { LineItemEditComponent } from 'ish-shared/components/line-item/line-item-edit/line-item-edit.component'; import { LineItemWarrantyComponent } from 'ish-shared/components/line-item/line-item-warranty/line-item-warranty.component'; import { ProductBundleDisplayComponent } from 'ish-shared/components/product/product-bundle-display/product-bundle-display.component'; @@ -37,7 +37,7 @@ describe('Line Item List Element Component', () => { let element: HTMLElement; let context: ProductContextFacade; - async function prepareTestbed(serverSetting: boolean) { + beforeEach(async () => { context = mock(ProductContextFacade); when(context.select('product')).thenReturn(of({} as ProductView)); when(context.select('quantity')).thenReturn(EMPTY); @@ -49,6 +49,7 @@ describe('Line Item List Element Component', () => { MockComponent(FaIconComponent), MockComponent(LazyProductAddToOrderTemplateComponent), MockComponent(LazyProductAddToWishlistComponent), + MockComponent(LineItemCustomFieldsComponent), MockComponent(LineItemEditComponent), MockComponent(LineItemWarrantyComponent), MockComponent(ProductBundleDisplayComponent), @@ -63,131 +64,98 @@ describe('Line Item List Element Component', () => { MockDirective(NgbPopover), MockDirective(ProductContextDirective), MockPipe(PricePipe), - MockPipe(ServerSettingPipe, () => serverSetting), ], providers: [ { provide: CheckoutFacade, useFactory: () => instance(mock(CheckoutFacade)) }, { provide: ProductContextFacade, useFactory: () => instance(context) }, ], }).compileComponents(); - } - - describe('b2c variation handling', () => { - beforeEach(async () => { - prepareTestbed(false); - }); + }); - beforeEach(() => { - fixture = TestBed.createComponent(LineItemListElementComponent); - component = fixture.componentInstance; - element = fixture.nativeElement; - component.pli = BasketMockData.getBasketItem(); - }); + beforeEach(() => { + fixture = TestBed.createComponent(LineItemListElementComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + component.pli = BasketMockData.getBasketItem(); + }); - it('should be created', () => { - expect(component).toBeTruthy(); - expect(element).toBeTruthy(); - expect(() => fixture.detectChanges()).not.toThrow(); - }); + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); - describe('editable', () => { - beforeEach(() => { - component.editable = true; - }); - - it('should render item quantity change input field if editable === true', () => { - fixture.detectChanges(); - expect(element.querySelector('ish-product-quantity')).toBeTruthy(); - }); - - it('should not render item quantity change input field if editable === false', () => { - component.editable = false; - fixture.detectChanges(); - expect(element.querySelector('ish-product-quantity')).not.toBeTruthy(); - }); - - it('should render item delete button if editable === true', () => { - fixture.detectChanges(); - expect(element.querySelector('fa-icon[ng-reflect-icon="fas,trash-alt"]')).toBeTruthy(); - }); - - it('should not render item delete button if editable === false', () => { - component.editable = false; - fixture.detectChanges(); - expect(element.querySelector('fa-icon[ng-reflect-icon="fas,trash-alt"]')).toBeFalsy(); - }); + describe('editable', () => { + beforeEach(() => { + component.editable = true; }); - it('should give correct sku to productIdComponent', () => { + it('should render item quantity change input field if editable === true', () => { fixture.detectChanges(); - expect(element.querySelector('ish-product-id')).toMatchInlineSnapshot(``); + expect(element.querySelector('ish-product-quantity')).toBeTruthy(); }); - it('should hold itemSurcharges for the line item', () => { + it('should not render item quantity change input field if editable === false', () => { + component.editable = false; fixture.detectChanges(); - expect(element.querySelectorAll('.details-tooltip')).toHaveLength(1); - }); - - it('should not display itemSurcharges for the line item if not available', () => { - component.pli = { ...BasketMockData.getBasketItem(), itemSurcharges: undefined }; - expect(() => fixture.detectChanges()).not.toThrow(); - expect(element.querySelectorAll('.details-tooltip')).toHaveLength(0); + expect(element.querySelector('ish-product-quantity')).not.toBeTruthy(); }); - it('should display standard elements for normal products', () => { + it('should render item delete button if editable === true', () => { fixture.detectChanges(); - expect(findAllCustomElements(element)).toMatchInlineSnapshot(` - [ - "ish-product-image", - "ish-product-name", - "ish-product-id", - "ish-product-variation-display", - "ish-product-bundle-display", - "ish-line-item-edit", - "ish-product-inventory", - "ish-product-shipment", - "fa-icon", - "ish-lazy-product-add-to-order-template", - "ish-lazy-product-add-to-wishlist", - "fa-icon", - "ish-product-quantity-label", - "ish-product-quantity", - "ish-product-quantity", - "ish-line-item-warranty", - ] - `); + expect(element.querySelector('fa-icon[ng-reflect-icon="fas,trash-alt"]')).toBeTruthy(); }); - it('should display bundle parts for bundle products', () => { - when(context.select('product')).thenReturn(of({ type: 'Bundle' } as ProductView)); + it('should not render item delete button if editable === false', () => { + component.editable = false; fixture.detectChanges(); - expect(findAllCustomElements(element)).toContain('ish-product-bundle-display'); + expect(element.querySelector('fa-icon[ng-reflect-icon="fas,trash-alt"]')).toBeFalsy(); }); }); - describe('advanced variation handling', () => { - beforeEach(async () => { - prepareTestbed(true); - }); + it('should give correct sku to productIdComponent', () => { + fixture.detectChanges(); + expect(element.querySelector('ish-product-id')).toMatchInlineSnapshot(``); + }); - beforeEach(() => { - fixture = TestBed.createComponent(LineItemListElementComponent); - component = fixture.componentInstance; - element = fixture.nativeElement; - component.pli = BasketMockData.getBasketItem(); - }); + it('should hold itemSurcharges for the line item', () => { + fixture.detectChanges(); + expect(element.querySelectorAll('.details-tooltip')).toHaveLength(1); + }); - it('should be created', () => { - expect(component).toBeTruthy(); - expect(element).toBeTruthy(); - expect(() => fixture.detectChanges()).not.toThrow(); - }); + it('should not display itemSurcharges for the line item if not available', () => { + component.pli = { ...BasketMockData.getBasketItem(), itemSurcharges: undefined }; + expect(() => fixture.detectChanges()).not.toThrow(); + expect(element.querySelectorAll('.details-tooltip')).toHaveLength(0); + }); - it('should not display edit component for variation products with advanced variation handling', () => { - when(context.select('product')).thenReturn(of({ type: 'VariationProduct' } as ProductView)); + it('should display standard elements for normal products', () => { + fixture.detectChanges(); + expect(findAllCustomElements(element)).toMatchInlineSnapshot(` + [ + "ish-product-image", + "ish-product-name", + "ish-product-id", + "ish-line-item-custom-fields", + "ish-product-variation-display", + "ish-product-bundle-display", + "ish-product-inventory", + "ish-product-shipment", + "fa-icon", + "ish-lazy-product-add-to-order-template", + "ish-lazy-product-add-to-wishlist", + "fa-icon", + "ish-line-item-edit", + "ish-product-quantity-label", + "ish-product-quantity", + "ish-product-quantity", + ] + `); + }); - fixture.detectChanges(); - expect(findAllCustomElements(element)).not.toContain('ish-line-item-edit'); - }); + it('should display bundle parts for bundle products', () => { + when(context.select('product')).thenReturn(of({ type: 'Bundle' } as ProductView)); + fixture.detectChanges(); + expect(findAllCustomElements(element)).toContain('ish-product-bundle-display'); }); }); diff --git a/src/app/shared/components/line-item/line-item-list-element/line-item-list-element.component.ts b/src/app/shared/components/line-item/line-item-list-element/line-item-list-element.component.ts index 38cfd6c64e..bdea79e7ee 100644 --- a/src/app/shared/components/line-item/line-item-list-element/line-item-list-element.component.ts +++ b/src/app/shared/components/line-item/line-item-list-element/line-item-list-element.component.ts @@ -1,5 +1,6 @@ -import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { isEqual } from 'lodash-es'; +import { Subject, Subscription, takeUntil } from 'rxjs'; import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; @@ -12,17 +13,30 @@ import { OrderLineItem } from 'ish-core/models/order/order.model'; templateUrl: './line-item-list-element.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class LineItemListElementComponent implements OnInit { +export class LineItemListElementComponent implements OnChanges, OnDestroy { @Input({ required: true }) pli: Partial; @Input() editable = true; @Input() lineItemViewType: 'simple' | 'availability'; + private updateSubscription: Subscription; + private destroy$ = new Subject(); + constructor(private context: ProductContextFacade, private checkoutFacade: CheckoutFacade) {} - ngOnInit() { - this.context.hold(this.context.validDebouncedQuantityUpdate$(), quantity => { - this.checkoutFacade.updateBasketItem({ itemId: this.pli.id, quantity }); - }); + ngOnChanges(changes: SimpleChanges): void { + if (changes.pli) { + if (this.updateSubscription) { + // eslint-disable-next-line ban/ban + this.updateSubscription.unsubscribe(); + } + + this.updateSubscription = this.context + .validDebouncedQuantityUpdate$() + .pipe(takeUntil(this.destroy$)) + .subscribe(quantity => { + this.checkoutFacade.updateBasketItem({ itemId: this.pli.id, quantity }); + }); + } } get oldPrice() { @@ -38,4 +52,9 @@ export class LineItemListElementComponent implements OnInit { onDeleteItem(itemId: string) { this.checkoutFacade.deleteBasketItem(itemId); } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/src/app/shared/components/line-item/line-item-list/line-item-list.component.html b/src/app/shared/components/line-item/line-item-list/line-item-list.component.html index 275124283d..ddedbac7c7 100644 --- a/src/app/shared/components/line-item/line-item-list/line-item-list.component.html +++ b/src/app/shared/components/line-item/line-item-list/line-item-list.component.html @@ -12,7 +12,7 @@
this.pageSize; } - trackByFn(_: number, item: Partial) { - return item.id; - } - /** * Refresh items to display after changing the current page * diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 54afa72352..b068957871 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -68,6 +68,7 @@ import { BasketApprovalInfoComponent } from './components/basket/basket-approval import { BasketBuyerComponent } from './components/basket/basket-buyer/basket-buyer.component'; import { BasketCostCenterSelectionComponent } from './components/basket/basket-cost-center-selection/basket-cost-center-selection.component'; import { BasketCostSummaryComponent } from './components/basket/basket-cost-summary/basket-cost-summary.component'; +import { BasketCustomFieldsComponent } from './components/basket/basket-custom-fields/basket-custom-fields.component'; import { BasketDesiredDeliveryDateComponent } from './components/basket/basket-desired-delivery-date/basket-desired-delivery-date.component'; import { BasketErrorMessageComponent } from './components/basket/basket-error-message/basket-error-message.component'; import { BasketInfoComponent } from './components/basket/basket-info/basket-info.component'; @@ -83,6 +84,7 @@ import { BasketValidationProductsComponent } from './components/basket/basket-va import { BasketValidationResultsComponent } from './components/basket/basket-validation-results/basket-validation-results.component'; import { ClearBasketComponent } from './components/basket/clear-basket/clear-basket.component'; import { MiniBasketContentComponent } from './components/basket/mini-basket-content/mini-basket-content.component'; +import { BasketCustomFieldsViewComponent } from './components/checkout/basket-custom-fields-view/basket-custom-fields-view.component'; import { BasketInvoiceAddressWidgetComponent } from './components/checkout/basket-invoice-address-widget/basket-invoice-address-widget.component'; import { BasketShippingAddressWidgetComponent } from './components/checkout/basket-shipping-address-widget/basket-shipping-address-widget.component'; import { AccordionItemComponent } from './components/common/accordion-item/accordion-item.component'; @@ -97,6 +99,8 @@ import { ModalDialogLinkComponent } from './components/common/modal-dialog-link/ import { ModalDialogComponent } from './components/common/modal-dialog/modal-dialog.component'; import { PagingComponent } from './components/common/paging/paging.component'; import { SuccessMessageComponent } from './components/common/success-message/success-message.component'; +import { CustomFieldsFormlyComponent } from './components/custom-fields/custom-fields-formly/custom-fields-formly.component'; +import { CustomFieldsViewComponent } from './components/custom-fields/custom-fields-view/custom-fields-view.component'; import { FilterCheckboxComponent } from './components/filter/filter-checkbox/filter-checkbox.component'; import { FilterCollapsibleComponent } from './components/filter/filter-collapsible/filter-collapsible.component'; import { FilterDropdownComponent } from './components/filter/filter-dropdown/filter-dropdown.component'; @@ -106,8 +110,10 @@ import { FilterNavigationSidebarComponent } from './components/filter/filter-nav import { FilterNavigationComponent } from './components/filter/filter-navigation/filter-navigation.component'; 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'; @@ -228,6 +234,7 @@ const declaredComponents = [ FilterTextComponent, LineItemEditComponent, LineItemEditDialogComponent, + LineItemInformationEditComponent, LineItemListElementComponent, LineItemWarrantyComponent, LoginModalComponent, @@ -247,6 +254,7 @@ const exportedComponents = [ AccordionComponent, AccordionItemComponent, AddressComponent, + BasketCustomFieldsComponent, BasketAddressSummaryComponent, BasketApprovalInfoComponent, BasketBuyerComponent, @@ -311,6 +319,10 @@ const exportedComponents = [ ProductVariationSelectSwatchComponent, ProductWarrantyComponent, ProductWarrantyDetailsComponent, + CustomFieldsFormlyComponent, + LineItemCustomFieldsComponent, + CustomFieldsViewComponent, + BasketCustomFieldsViewComponent, PromotionDetailsComponent, PromotionRemoveComponent, SearchBoxComponent, diff --git a/src/assets/i18n/de_DE.json b/src/assets/i18n/de_DE.json index 79a5b00818..25d6fca5b5 100644 --- a/src/assets/i18n/de_DE.json +++ b/src/assets/i18n/de_DE.json @@ -715,6 +715,7 @@ "checkout.credit_card.user.lastname.missing.error": "Bitte geben Sie den Nachnamen des Karteninhabers an.", "checkout.credit_card.user.name.missing.error": "Bitte geben Sie den Namen des Karteninhabers an.", "checkout.credit_card.validityTime.error.notFound": "Die maximale Gültigkeitsdauer der Kartenprüfnummer wurde nicht gefunden.", + "checkout.custom-field.no-value": "Kein Wert", "checkout.desired_delivery_date.apply.button.label": "Übernehmen", "checkout.desired_delivery_date.error.date": "Das Datum ist ungültig. Bitte verwenden Sie das korrekte Format ({{ translate, common.placeholder.shortdate-caps }}).", "checkout.desired_delivery_date.error.max_date": "Ihr gewünschter Liefertermin kann nicht realisiert werden, weil das Datum zu weit in der Zukunft liegt.", diff --git a/src/assets/i18n/en_US.json b/src/assets/i18n/en_US.json index 40f92a42b0..c533a219bc 100644 --- a/src/assets/i18n/en_US.json +++ b/src/assets/i18n/en_US.json @@ -715,6 +715,16 @@ "checkout.credit_card.user.lastname.missing.error": "Please enter a cardholder last name.", "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.", @@ -823,12 +833,13 @@ "checkout.tax.text": "Tax", "checkout.termsandconditions.details.title": "Terms & conditions", "checkout.update.label": "Edit {{0}}", - "checkout.variation.edit.button.label": "Edit", + "checkout.variation.edit.button.label": "Edit variation", "checkout.widget.billing-address.heading": "Invoice address", "checkout.widget.buyer.TaxationID": "Taxation ID:", "checkout.widget.buyer.costcenter": "Cost center", "checkout.widget.buyer.heading": "Buyer", "checkout.widget.buyer.orderReferenceId": "Customer order ID", + "checkout.widget.custom_fields": "Custom fields", "checkout.widget.message_to_merchant": "Message to merchant", "checkout.widget.payment_method.heading": "Payment method", "checkout.widget.promotion.discount": "Discount", diff --git a/src/styles/pages/checkout/shopping-cart.scss b/src/styles/pages/checkout/shopping-cart.scss index 8dc9fc4a58..3e9a7eacb0 100644 --- a/src/styles/pages/checkout/shopping-cart.scss +++ b/src/styles/pages/checkout/shopping-cart.scss @@ -186,3 +186,11 @@ max-width: 100%; } } + +.list-item-actions { + padding: $table-cell-padding * 0.2 0 $table-cell-padding * 0.9 0; + + .btn-tool.btn-link { + padding: 0 divide($space-default, 3); + } +}