diff --git a/docs/guides/migrations.md b/docs/guides/migrations.md index 71e3014015..b22e0821fb 100644 --- a/docs/guides/migrations.md +++ b/docs/guides/migrations.md @@ -24,6 +24,9 @@ After entering a desired delivery date on the checkout shipping page and after s In case of large basket (> 20 items) this might cause (unacceptable) long response times. You can keep the existing behavior by modifying the updateBasketItemsDesiredDeliveryDate() method of the basket service to always return an empty array without doing anything. +The `ProductsService` was changed to use `extended=true` REST calls for product details and variations to fetch variation attributes with additional `attributeType` and `metaData` information that can be used to control the rendering of different variation select types. +The added `VariationAttributeMapper` maps the additional information in a backwards compatible way. + ## 3.0 to 3.1 The SSR environment variable 'ICM_IDENTITY_PROVIDER' will be removed in a future release ( PWA 5.0 ). diff --git a/src/app/core/models/image/image.mapper.ts b/src/app/core/models/image/image.mapper.ts index c768644686..0e62405dea 100644 --- a/src/app/core/models/image/image.mapper.ts +++ b/src/app/core/models/image/image.mapper.ts @@ -53,7 +53,7 @@ export class ImageMapper { * @param url The relative or absolute image URL. * @returns The URL. */ - private fromEffectiveUrl(url: string): string { + fromEffectiveUrl(url: string): string { if (!url) { return; } diff --git a/src/app/core/models/product-variation/product-variation.helper.spec.ts b/src/app/core/models/product-variation/product-variation.helper.spec.ts index d28c5aa228..eff3231a8e 100644 --- a/src/app/core/models/product-variation/product-variation.helper.spec.ts +++ b/src/app/core/models/product-variation/product-variation.helper.spec.ts @@ -9,8 +9,8 @@ const productVariations = [ sku: '222', productMasterSKU: 'M111', variableVariationAttributes: [ - { name: 'Attr 1', type: 'String', value: 'A', variationAttributeId: 'a1' }, - { name: 'Attr 2', type: 'String', value: 'A', variationAttributeId: 'a2' }, + { name: 'Attr 1', value: 'A', variationAttributeId: 'a1' }, + { name: 'Attr 2', value: 'A', variationAttributeId: 'a2' }, ], }, { @@ -18,32 +18,32 @@ const productVariations = [ productMasterSKU: 'M111', attributes: [{ name: 'defaultVariation', type: 'Boolean', value: true }], variableVariationAttributes: [ - { name: 'Attr 1', type: 'String', value: 'A', variationAttributeId: 'a1' }, - { name: 'Attr 2', type: 'String', value: 'B', variationAttributeId: 'a2' }, + { name: 'Attr 1', value: 'A', variationAttributeId: 'a1' }, + { name: 'Attr 2', value: 'B', variationAttributeId: 'a2' }, ], }, { sku: '444', productMasterSKU: 'M111', variableVariationAttributes: [ - { name: 'Attr 1', type: 'String', value: 'B', variationAttributeId: 'a1' }, - { name: 'Attr 2', type: 'String', value: 'A', variationAttributeId: 'a2' }, + { name: 'Attr 1', value: 'B', variationAttributeId: 'a1' }, + { name: 'Attr 2', value: 'A', variationAttributeId: 'a2' }, ], }, { sku: '555', productMasterSKU: 'M111', variableVariationAttributes: [ - { name: 'Attr 1', type: 'String', value: 'B', variationAttributeId: 'a1' }, - { name: 'Attr 2', type: 'String', value: 'B', variationAttributeId: 'a2' }, + { name: 'Attr 1', value: 'B', variationAttributeId: 'a1' }, + { name: 'Attr 2', value: 'B', variationAttributeId: 'a2' }, ], }, { sku: '666', productMasterSKU: 'M111', variableVariationAttributes: [ - { name: 'Attr 1', type: 'String', value: 'B', variationAttributeId: 'a1' }, - { name: 'Attr 2', type: 'String', value: 'C', variationAttributeId: 'a2' }, + { name: 'Attr 1', value: 'B', variationAttributeId: 'a1' }, + { name: 'Attr 2', value: 'C', variationAttributeId: 'a2' }, ], }, ] as VariationProduct[]; @@ -51,11 +51,11 @@ const productVariations = [ const productMaster = { sku: 'M111', variationAttributeValues: [ - { name: 'Attr 1', type: 'String', value: 'A', variationAttributeId: 'a1' }, - { name: 'Attr 1', type: 'String', value: 'B', variationAttributeId: 'a1' }, - { name: 'Attr 2', type: 'String', value: 'A', variationAttributeId: 'a2' }, - { name: 'Attr 2', type: 'String', value: 'B', variationAttributeId: 'a2' }, - { name: 'Attr 2', type: 'String', value: 'C', variationAttributeId: 'a2' }, + { name: 'Attr 1', value: 'A', variationAttributeId: 'a1' }, + { name: 'Attr 1', value: 'B', variationAttributeId: 'a1' }, + { name: 'Attr 2', value: 'A', variationAttributeId: 'a2' }, + { name: 'Attr 2', value: 'B', variationAttributeId: 'a2' }, + { name: 'Attr 2', value: 'C', variationAttributeId: 'a2' }, ], } as VariationProductMaster; diff --git a/src/app/core/models/product-variation/product-variation.helper.ts b/src/app/core/models/product-variation/product-variation.helper.ts index 36dca3ce99..d718bb503d 100644 --- a/src/app/core/models/product-variation/product-variation.helper.ts +++ b/src/app/core/models/product-variation/product-variation.helper.ts @@ -33,6 +33,7 @@ export class ProductVariationHelper { label: attr.value, value: attr.value, type: attr.variationAttributeId, + metaData: attr.metaData, active: currentSettings?.[attr.variationAttributeId]?.value === attr.value, })) .map(option => ({ @@ -50,6 +51,7 @@ export class ProductVariationHelper { return { id: attribute.variationAttributeId, label: attribute.name, + attributeType: attribute.attributeType, options: groupedOptions[attrId], }; }); diff --git a/src/app/core/models/product-variation/variation-attribute.interface.ts b/src/app/core/models/product-variation/variation-attribute.interface.ts new file mode 100644 index 0000000000..02a49ff411 --- /dev/null +++ b/src/app/core/models/product-variation/variation-attribute.interface.ts @@ -0,0 +1,23 @@ +import { VariationAttributeType } from './variation-attribute.model'; + +export interface VariationAttributeData { + variationAttributeId: string; + name: string; + value?: VariationAttributeValue; + values?: { + value: VariationAttributeValue; + metadata?: VariationAttributeMetaData; + }[]; + attributeType?: VariationAttributeType; + metadata?: VariationAttributeMetaData; +} + +interface VariationAttributeValue { + name: string; + value: string; +} + +export interface VariationAttributeMetaData { + colorCode?: string; + imagePath?: string; +} diff --git a/src/app/core/models/product-variation/variation-attribute.mapper.ts b/src/app/core/models/product-variation/variation-attribute.mapper.ts new file mode 100644 index 0000000000..8709815809 --- /dev/null +++ b/src/app/core/models/product-variation/variation-attribute.mapper.ts @@ -0,0 +1,52 @@ +/* eslint-disable ish-custom-rules/project-structure */ +import { Injectable } from '@angular/core'; + +import { ImageMapper } from 'ish-core/models/image/image.mapper'; + +import { VariationAttributeData, VariationAttributeMetaData } from './variation-attribute.interface'; +import { VariationAttribute, VariationAttributeType } from './variation-attribute.model'; + +/** + * Maps variation attributes data of HTTP requests to client side model instance. + */ +@Injectable({ providedIn: 'root' }) +export class VariationAttributeMapper { + constructor(private imageMapper: ImageMapper) {} + + fromData(data: VariationAttributeData[]): VariationAttribute[] { + return data?.map(varAttr => ({ + variationAttributeId: varAttr.variationAttributeId, + name: varAttr.name, + value: varAttr.value.value, + attributeType: varAttr.attributeType, + metaData: this.mapMetaData(varAttr.attributeType, varAttr.metadata), + })); + } + + fromMasterData(variationAttributes: VariationAttributeData[]): VariationAttribute[] { + return variationAttributes + ?.map(varAttr => + varAttr.values.map(value => ({ + variationAttributeId: varAttr.variationAttributeId, + name: varAttr.name, + value: value.value.value, + attributeType: varAttr.attributeType, + metaData: this.mapMetaData(varAttr.attributeType, value.metadata), + })) + ) + .flat(); + } + + private mapMetaData(attributeType: VariationAttributeType, metaData: VariationAttributeMetaData): string { + switch (attributeType) { + case 'colorCode': + case 'defaultAndColorCode': + return metaData?.colorCode; + case 'swatchImage': + case 'defaultAndSwatchImage': + return this.imageMapper.fromEffectiveUrl(metaData?.imagePath); + default: + return; + } + } +} diff --git a/src/app/core/models/product-variation/variation-attribute.model.ts b/src/app/core/models/product-variation/variation-attribute.model.ts index a2641d1667..e93dd8bc4e 100644 --- a/src/app/core/models/product-variation/variation-attribute.model.ts +++ b/src/app/core/models/product-variation/variation-attribute.model.ts @@ -1,5 +1,14 @@ -import { Attribute } from 'ish-core/models/attribute/attribute.model'; - -export interface VariationAttribute extends Attribute { +export interface VariationAttribute { variationAttributeId: string; + name: string; + value: string; + attributeType: VariationAttributeType; + metaData?: string; } + +export type VariationAttributeType = + | 'default' + | 'colorCode' + | 'defaultAndColorCode' + | 'swatchImage' + | 'defaultAndSwatchImage'; diff --git a/src/app/core/models/product-variation/variation-option-group.model.ts b/src/app/core/models/product-variation/variation-option-group.model.ts index 97a2d7db39..7aef3971e8 100644 --- a/src/app/core/models/product-variation/variation-option-group.model.ts +++ b/src/app/core/models/product-variation/variation-option-group.model.ts @@ -4,4 +4,5 @@ export interface VariationOptionGroup { options: VariationSelectOption[]; label: string; id: string; + attributeType?: string; } diff --git a/src/app/core/models/product-variation/variation-select-option.model.ts b/src/app/core/models/product-variation/variation-select-option.model.ts index f34715dba9..ce2dcef470 100644 --- a/src/app/core/models/product-variation/variation-select-option.model.ts +++ b/src/app/core/models/product-variation/variation-select-option.model.ts @@ -4,4 +4,5 @@ export interface VariationSelectOption { type: string; alternativeCombination?: boolean; active?: boolean; + metaData?: string; } diff --git a/src/app/core/models/product/product.interface.ts b/src/app/core/models/product/product.interface.ts index d9cd92c4d5..ffc80ba363 100644 --- a/src/app/core/models/product/product.interface.ts +++ b/src/app/core/models/product/product.interface.ts @@ -5,7 +5,7 @@ import { CategoryData } from 'ish-core/models/category/category.interface'; import { Image } from 'ish-core/models/image/image.model'; import { Link } from 'ish-core/models/link/link.model'; import { PriceData } from 'ish-core/models/price/price.interface'; -import { VariationAttribute } from 'ish-core/models/product-variation/variation-attribute.model'; +import { VariationAttributeData } from 'ish-core/models/product-variation/variation-attribute.interface'; import { SeoAttributesData } from 'ish-core/models/seo-attributes/seo-attributes.interface'; import { Warranty } from 'ish-core/models/warranty/warranty.model'; @@ -41,8 +41,7 @@ export interface ProductData { stepOrderQuantity?: number; packingUnit: string; - variationAttributeValues?: VariationAttribute[]; - variableVariationAttributes?: VariationAttribute[]; + variationAttributeValuesExtended?: VariationAttributeData[]; partOfRetailSet: boolean; attachments?: AttachmentData[]; @@ -74,5 +73,5 @@ export interface ProductDataStub { } export interface ProductVariationLink extends Link { - variableVariationAttributeValues: VariationAttribute[]; + variableVariationAttributeValuesExtended: VariationAttributeData[]; } diff --git a/src/app/core/models/product/product.mapper.spec.ts b/src/app/core/models/product/product.mapper.spec.ts index da6fe7e07f..e8d9b9f823 100644 --- a/src/app/core/models/product/product.mapper.spec.ts +++ b/src/app/core/models/product/product.mapper.spec.ts @@ -66,7 +66,7 @@ describe('Product Mapper', () => { const product: Product = productMapper.fromData({ sku: '1', productMaster: true, - variationAttributeValues: [], + variationAttributeValuesExtended: [], } as ProductData); expect(product).toBeTruthy(); expect(product.type).toEqual('VariationProductMaster'); @@ -78,7 +78,7 @@ describe('Product Mapper', () => { const product: Product = productMapper.fromData({ sku: '1', productMaster: false, - variableVariationAttributes: [], + variationAttributeValuesExtended: [], } as ProductData); expect(product).toBeTruthy(); expect(product.type).toEqual('Product'); diff --git a/src/app/core/models/product/product.mapper.ts b/src/app/core/models/product/product.mapper.ts index b1b97a161f..3f2e989b96 100644 --- a/src/app/core/models/product/product.mapper.ts +++ b/src/app/core/models/product/product.mapper.ts @@ -8,6 +8,7 @@ import { CategoryData } from 'ish-core/models/category/category.interface'; import { CategoryMapper } from 'ish-core/models/category/category.mapper'; import { ImageMapper } from 'ish-core/models/image/image.mapper'; import { Link } from 'ish-core/models/link/link.model'; +import { VariationAttributeMapper } from 'ish-core/models/product-variation/variation-attribute.mapper'; import { SeoAttributesMapper } from 'ish-core/models/seo-attributes/seo-attributes.mapper'; import { SkuQuantityType } from './product.helper'; @@ -43,7 +44,8 @@ export class ProductMapper { constructor( private imageMapper: ImageMapper, private attachmentMapper: AttachmentMapper, - private categoryMapper: CategoryMapper + private categoryMapper: CategoryMapper, + private variationAttributeMapper: VariationAttributeMapper ) {} static parseSkuFromURI(uri: string): string { @@ -91,7 +93,9 @@ export class ProductMapper { fromVariationLink(link: ProductVariationLink, productMasterSKU: string): Partial { return { ...this.fromLink(link), - variableVariationAttributes: link.variableVariationAttributeValues, + variableVariationAttributes: this.variationAttributeMapper.fromData( + link.variableVariationAttributeValuesExtended + ), productMasterSKU, type: 'VariationProduct', failed: false, @@ -227,14 +231,14 @@ export class ProductMapper { if (data.productMaster) { return { ...product, - variationAttributeValues: data.variationAttributeValues, + variationAttributeValues: this.variationAttributeMapper.fromMasterData(data.variationAttributeValuesExtended), type: 'VariationProductMaster', }; } else if (data.mastered) { return { ...product, productMasterSKU: data.productMasterSKU, - variableVariationAttributes: data.variableVariationAttributes, + variableVariationAttributes: this.variationAttributeMapper.fromData(data.variationAttributeValuesExtended), type: 'VariationProduct', }; } else if (data.productTypes?.includes('BUNDLE') || data.productBundle) { diff --git a/src/app/core/services/products/products.service.spec.ts b/src/app/core/services/products/products.service.spec.ts index 6310374dfb..4d364f8f03 100644 --- a/src/app/core/services/products/products.service.spec.ts +++ b/src/app/core/services/products/products.service.spec.ts @@ -129,21 +129,23 @@ describe('Products Service', () => { it("should get all product variations data when 'getProductVariations' is called and more than 50 variations exist", done => { const total = 156; when(apiServiceMock.get(`products/${productSku}/variations`, anything())).thenCall((_, opts) => - !opts.params ? of({ elements: [], amount: 40, total }) : of({ elements: [], total }) + !opts?.params.has('amount') ? of({ elements: [], amount: 40, total }) : of({ elements: [], total }) ); productsService.getProductVariations(productSku).subscribe(() => { verify(apiServiceMock.get(`products/${productSku}/variations`, anything())).times(4); - expect(capture(apiServiceMock.get).byCallIndex(0)?.[1]?.params).toBeUndefined(); + expect( + capture(apiServiceMock.get).byCallIndex(0)?.[1]?.params?.toString() + ).toMatchInlineSnapshot(`"extended=true"`); expect( capture(apiServiceMock.get).byCallIndex(1)?.[1]?.params?.toString() - ).toMatchInlineSnapshot(`"amount=40&offset=40"`); + ).toMatchInlineSnapshot(`"extended=true&amount=40&offset=40"`); expect( capture(apiServiceMock.get).byCallIndex(2)?.[1]?.params?.toString() - ).toMatchInlineSnapshot(`"amount=40&offset=80"`); + ).toMatchInlineSnapshot(`"extended=true&amount=40&offset=80"`); expect( capture(apiServiceMock.get).byCallIndex(3)?.[1]?.params?.toString() - ).toMatchInlineSnapshot(`"amount=36&offset=120"`); + ).toMatchInlineSnapshot(`"extended=true&amount=36&offset=120"`); done(); }); }); diff --git a/src/app/core/services/products/products.service.ts b/src/app/core/services/products/products.service.ts index 1d0f5b3818..4bb6278eda 100644 --- a/src/app/core/services/products/products.service.ts +++ b/src/app/core/services/products/products.service.ts @@ -44,7 +44,7 @@ export class ProductsService { return throwError(() => new Error('getProduct() called without a sku')); } - const params = new HttpParams().set('allImages', 'true'); + const params = new HttpParams().set('allImages', true).set('extended', true); return this.apiService .get(`products/${sku}`, { sendSPGID: true, params }) @@ -260,8 +260,13 @@ export class ProductsService { return throwError(() => new Error('getProductVariations() called without a sku')); } + const params = new HttpParams().set('extended', true); + return this.apiService - .get<{ elements: Link[]; total: number; amount: number }>(`products/${sku}/variations`, { sendSPGID: true }) + .get<{ elements: Link[]; total: number; amount: number }>(`products/${sku}/variations`, { + sendSPGID: true, + params, + }) .pipe( switchMap(resp => !resp.total @@ -277,7 +282,7 @@ export class ProductsService { this.apiService .get<{ elements: Link[] }>(`products/${sku}/variations`, { sendSPGID: true, - params: new HttpParams().set('amount', length).set('offset', offset), + params: params.set('amount', length).set('offset', offset), }) .pipe(mapToProperty('elements')) )