diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 1ed53e7e6ed..1daaa1c34ba 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -2051,6 +2051,7 @@ export namespace Components { "field": string; /** * The product field that contains the alt text for the images. This will look for the field in the product object first, then in the product.additionalFields object. The field can be a string or an array of strings. If the value of the field is a string, it will be used as the alt text for all the images. If the value of the field is an array of strings, the alt text will be used in the order of the images. If the field is not specified, or does not contain a valid value, the alt text will be set to "Image {index} out of {totalImages} for {productName}". + * @type {string} */ "imageAltField"?: string; /** @@ -7935,6 +7936,7 @@ declare namespace LocalJSX { "field"?: string; /** * The product field that contains the alt text for the images. This will look for the field in the product object first, then in the product.additionalFields object. The field can be a string or an array of strings. If the value of the field is a string, it will be used as the alt text for all the images. If the value of the field is an array of strings, the alt text will be used in the order of the images. If the field is not specified, or does not contain a valid value, the alt text will be set to "Image {index} out of {totalImages} for {productName}". + * @type {string} */ "imageAltField"?: string; } diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/atomic-product-image.new.stories.tsx b/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/atomic-product-image.new.stories.tsx new file mode 100644 index 00000000000..260294c6f75 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/atomic-product-image.new.stories.tsx @@ -0,0 +1,70 @@ +import {wrapInCommerceInterface} from '@coveo/atomic-storybook-utils/commerce/commerce-interface-wrapper'; +import {wrapInCommerceProductList} from '@coveo/atomic-storybook-utils/commerce/commerce-product-list-wrapper'; +import {wrapInProductTemplate} from '@coveo/atomic-storybook-utils/commerce/commerce-product-template-wrapper'; +import {parameters} from '@coveo/atomic-storybook-utils/common/common-meta-parameters'; +import {renderComponent} from '@coveo/atomic-storybook-utils/common/render-component'; +import type {Meta, StoryObj as Story} from '@storybook/web-components'; +import type {Decorator} from '@storybook/web-components'; +import {html} from 'lit-html'; + +const styledDivDecorator: Decorator = (story) => { + return html`
${story()}
`; +}; + +const { + decorator: commerceInterfaceDecorator, + play: initializeCommerceInterface, +} = wrapInCommerceInterface({ + skipFirstSearch: false, + type: 'product-listing', + engineConfig: { + context: { + view: { + url: 'https://ui-kit.coveo/atomic/storybook/atomic-product-image', + }, + language: 'en', + country: 'US', + currency: 'USD', + }, + }, +}); +const {decorator: commerceProductListDecorator} = wrapInCommerceProductList(); +const {decorator: productTemplateDecorator} = wrapInProductTemplate(); + +const meta: Meta = { + component: 'atomic-product-image', + title: 'Atomic-Commerce/Product Template Components/ProductImage', + id: 'atomic-product-image', + render: renderComponent, + decorators: [ + productTemplateDecorator, + commerceProductListDecorator, + commerceInterfaceDecorator, + styledDivDecorator, + ], + parameters, + play: initializeCommerceInterface, +}; + +export default meta; + +export const Default: Story = { + name: 'atomic-product-image', +}; + +export const withAFallbackImage: Story = { + name: 'With a fallback image', + args: { + 'attributes-field': 'invalid', + 'attributes-fallback': 'https://sports.barca.group/logos/barca.svg', + }, +}; + +export const withAnAltTextField: Story = { + name: 'With an alt text field', + args: { + 'attributes-field': 'invalid', + 'attributes-fallback': 'invalid', + 'attributes-image-alt-field': 'ec_name', + }, +}; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/atomic-product-image.tsx b/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/atomic-product-image.tsx index 94be915b12f..8f3ecaf609a 100644 --- a/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/atomic-product-image.tsx +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/atomic-product-image.tsx @@ -47,6 +47,7 @@ export class AtomicProductImage implements InitializableComponent { * If the value of the field is an array of strings, the alt text will be used in the order of the images. * * If the field is not specified, or does not contain a valid value, the alt text will be set to "Image {index} out of {totalImages} for {productName}". + * @type {string} */ @Prop({reflect: true}) imageAltField?: string; @@ -170,6 +171,10 @@ export class AtomicProductImage implements InitializableComponent { this.product, this.imageAltField ); + // KIT-3620 + // if (isNullOrUndefined(value)) { + // return null; + // } if (Array.isArray(value)) { return value.map((v) => `${v}`.trim()); @@ -208,9 +213,10 @@ export class AtomicProductImage implements InitializableComponent { }); if (this.images.length === 0) { this.validateUrl(this.fallback); - return ( {this.bindings.i18n.t('image-not-found-alt')} { } return ( - // TODO: handle small/icon image sizes better on mobile + // TODO - KIT-3612 : handle small/icon image sizes better on mobile { -// test.describe('when clicking on the next button', async ({productImage}) => { -// test.fixme('should navigate to the next image', () => {}); -// test.fixme('should not open the product', () => {}); -// }); -// test.describe('when clicking on the previous button', async ({productImage}) => { -// test.fixme('should navigate to the previous image', () => {}); -// test.fixme('should not open the product', () => {}); -// }); -// }); +/* eslint-disable @cspell/spellchecker */ +import {test, expect} from './fixture'; + +test.describe('default', async () => { + test.beforeEach(async ({productImage}) => { + await productImage.load(); + await productImage.noCarouselImage.waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + test('should render the image', async ({productImage}) => { + expect(productImage.noCarouselImage).toBeVisible(); + }); + + test('should have a default alt text', async ({productImage}) => { + const altText = await productImage.noCarouselImage.getAttribute('alt'); + expect(altText).toEqual('Image 1 out of 1 for Nublu Water Bottle'); + }); + + test('should have a 1:1 aspect ratio', async ({productImage}) => { + const aspectRatio = + await productImage.noCarouselImage.getAttribute('class'); + expect(aspectRatio).toEqual('aspect-square'); + }); +}); + +test.describe('with a custom fallback image', async () => { + const FALLBACK = 'https://sports.barca.group/logos/barca.svg'; + + test.describe('when the product image is missing', () => { + test.beforeEach(async ({productImage}) => { + await productImage.withCustomThumbnails([]); + await productImage.load({story: 'with-a-fallback-image'}); + await productImage.noCarouselImage.waitFor(); + }); + + test('should render the fallback image', async ({productImage}) => { + const src = await productImage.noCarouselImage.getAttribute('src'); + expect(src).toContain(FALLBACK); + }); + + //KIT-3619 + test.fixme('should have a 1:1 aspect ratio', async ({productImage}) => { + const aspectRatio = + await productImage.noCarouselImage.getAttribute('class'); + expect(aspectRatio).toEqual('aspect-square'); + }); + }); + + test.describe('when the product image is invalid', () => { + test.beforeEach(async ({productImage}) => { + await productImage.withCustomThumbnails(['invalid-image']); + await productImage.load({story: 'with-a-fallback-image'}); + }); + + test('should render the fallback image', async ({productImage}) => { + const src = await productImage.noCarouselImage.getAttribute('src'); + expect(src).toContain(FALLBACK); + }); + + //KIT-3619 + test.fixme('should have a 1:1 aspect ratio', async ({productImage}) => { + const aspectRatio = + await productImage.noCarouselImage.getAttribute('class'); + expect(aspectRatio).toEqual('aspect-square'); + }); + }); + + test.describe('when the product image is not a string', () => { + test.beforeEach(async ({productImage}) => { + await productImage.withCustomThumbnails([1]); + await productImage.load({story: 'with-a-fallback-image'}); + }); + + test('should render the fallback image', async ({productImage}) => { + const src = await productImage.noCarouselImage.getAttribute('src'); + expect(src).toContain(FALLBACK); + }); + + //KIT-3619 + test.fixme('should have a 1:1 aspect ratio', async ({productImage}) => { + const aspectRatio = + await productImage.noCarouselImage.getAttribute('class'); + expect(aspectRatio).toEqual('aspect-square'); + }); + }); +}); + +test.describe('with an alt text field', async () => { + test.describe('when imageAltField is a valid string', () => { + const NO_CAROUSEL_CUSTOM_FIELD = 'Nublu Water Bottle'; + const CAROUSEL_CUSTOM_FIELD = 'Blue Lagoon Mat'; + + test.beforeEach(async ({productImage}) => { + await productImage.withCustomField( + 'Nublu Water Bottle', + 'Blue Lagoon Mat' + ); + await productImage.load({ + story: 'with-an-alt-text-field', + args: { + field: 'ec_thumbnails', + fallback: undefined, + imageAltField: 'custom_alt_field', + }, + }); + await productImage.noCarouselImage.waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + test('should use the same alt text for all images', async ({ + productImage, + }) => { + const altNoCarousel = + await productImage.noCarouselImage.getAttribute('alt'); + expect(altNoCarousel).toEqual(NO_CAROUSEL_CUSTOM_FIELD); + + const altCarousel = await productImage.carouselImage.getAttribute('alt'); + expect(altCarousel).toEqual(CAROUSEL_CUSTOM_FIELD); + }); + }); + + test.describe('when imageAltField is an array of valid strings', () => { + const NO_CAROUSEL_CUSTOM_FIELDS = [ + 'FIRST Nublu Water Bottle', + 'SECOND Nublu Water Bottle 2', + ]; + const CAROUSEL_CUSTOM_FIELDS = [ + 'FIRST Blue Lagoon Mat', + 'SECOND Blue Lagoon Mat', + ]; + + test.beforeEach(async ({productImage}) => { + await productImage.withCustomField( + NO_CAROUSEL_CUSTOM_FIELDS, + CAROUSEL_CUSTOM_FIELDS + ); + await productImage.load({ + story: 'with-an-alt-text-field', + args: { + field: 'ec_thumbnails', + fallback: undefined, + imageAltField: 'custom_alt_field', + }, + }); + await productImage.noCarouselImage.waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + test('should correctly assign alt text for the first image', async ({ + productImage, + }) => { + const noCarouselAlt = + await productImage.noCarouselImage.getAttribute('alt'); + expect(noCarouselAlt).toContain(NO_CAROUSEL_CUSTOM_FIELDS[0]); + + const carouselAlt = await productImage.carouselImage.getAttribute('alt'); + expect(carouselAlt).toContain(CAROUSEL_CUSTOM_FIELDS[0]); + }); + + test('should correctly assign alt text for the last image', async ({ + productImage, + }) => { + await productImage.nextButton.click(); + + await expect + .poll(async () => { + return await productImage.carouselImage.getAttribute('alt'); + }) + .toContain(CAROUSEL_CUSTOM_FIELDS[1]); + }); + }); + + test.describe('when imageAltField is not specified', () => { + test.beforeEach(async ({productImage}) => { + await productImage.load(); + await productImage.noCarouselImage.waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + test('should generate default alt text for all images', async ({ + productImage, + }) => { + expect(await productImage.noCarouselImage.getAttribute('alt')).toEqual( + 'Image 1 out of 1 for Nublu Water Bottle' + ); + expect(await productImage.carouselImage.getAttribute('alt')).toEqual( + 'Image 1 out of 2 for Blue Lagoon Mat' + ); + await productImage.nextButton.click(); + await expect + .poll(async () => { + return await productImage.carouselImage.getAttribute('alt'); + }) + .toContain('Image 2 out of 2 for Blue Lagoon Mat'); + }); + }); + + test.describe('when imageAltField is invalid', () => { + test.beforeEach(async ({productImage}) => { + await productImage.load({ + story: 'with-an-alt-text-field', + args: { + field: 'ec_thumbnails', + fallback: undefined, + imageAltField: 'custom_alt_field', + }, + }); + await productImage.noCarouselImage.waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + //TODO: KIT-3620 + test.fixme( + 'should use the default alt text for all images', + async ({productImage, page}) => { + await page.waitForTimeout(10000); + expect(await productImage.noCarouselImage.getAttribute('alt')).toEqual( + 'Image 1 out of 1 for Nublu Water Bottle' + ); + expect(await productImage.carouselImage.getAttribute('alt')).toEqual( + 'Image 1 out of 2 for Blue Lagoon Mat' + ); + await productImage.nextButton.click(); + await expect + .poll(async () => { + return await productImage.carouselImage.getAttribute('alt'); + }) + .toContain('Image 2 out of 2 for Blue Lagoon Mat'); + } + ); + }); + + test.describe('when imageAltField is an empty array', () => { + test.beforeEach(async ({productImage}) => { + await productImage.withCustomField([], []); + await productImage.load({ + story: 'with-an-alt-text-field', + args: { + field: 'ec_thumbnails', + fallback: undefined, + imageAltField: 'custom_alt_field', + }, + }); + await productImage.noCarouselImage.waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + test('should use the default alt text for all images', async ({ + productImage, + }) => { + expect(await productImage.noCarouselImage.getAttribute('alt')).toEqual( + 'Image 1 out of 1 for Nublu Water Bottle' + ); + expect(await productImage.carouselImage.getAttribute('alt')).toEqual( + 'Image 1 out of 2 for Blue Lagoon Mat' + ); + await productImage.nextButton.click(); + await expect + .poll(async () => { + return await productImage.carouselImage.getAttribute('alt'); + }) + .toContain('Image 2 out of 2 for Blue Lagoon Mat'); + }); + }); +}); + +test.describe('as a carousel', async () => { + const URL = + 'http://localhost:4400/iframe.html?id=atomic-product-image--default&viewMode=story#sortCriteria=relevance'; + const FIRST_IMAGE = + 'https://images.barca.group/Sports/mj/Trampolines%20%26%20Floats/Huge%20inflatable%20mats/3_Blue/df1a99488425_bottom_right.webp'; + const SECOND_IMAGE = + 'https://images.barca.group/Sports/mj/Trampolines%20%26%20Floats/Huge%20inflatable%20mats/3_Blue/df1a99488425_bottom_left.webp'; + + test.beforeEach(async ({productImage}) => { + await productImage.load(); + await productImage.carouselImage.waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + test('should render the first image by default', async ({productImage}) => { + await expect(productImage.carouselImage).toBeVisible(); + const src = await productImage.carouselImage.getAttribute('src'); + expect(src).toContain(FIRST_IMAGE); + }); + + test.describe('when clicking the next button', () => { + test.beforeEach(async ({productImage}) => { + await productImage.nextButton.click(); + }); + + test('should navigate to the next image', async ({productImage}) => { + await expect + .poll(async () => { + const src = await productImage.carouselImage.getAttribute('src'); + return src; + }) + .toContain(SECOND_IMAGE); + }); + + test('should navigate to the first image if the last image is reached', async ({ + productImage, + }) => { + await productImage.nextButton.click(); + await expect + .poll(async () => { + return await productImage.carouselImage.getAttribute('src'); + }) + .toContain(FIRST_IMAGE); + }); + + test('should not open the product', async ({page}) => { + expect(page.url()).toEqual(URL); + }); + }); + + test.describe('when clicking the previous button', () => { + test.beforeEach(async ({productImage}) => { + await productImage.previousButton.click(); + }); + + test('should navigate to the last image if the first image is reached', async ({ + productImage, + }) => { + await expect + .poll(async () => { + const src = await productImage.carouselImage.getAttribute('src'); + return src; + }) + .toContain(SECOND_IMAGE); + }); + + test('should navigate to the previous image', async ({productImage}) => { + await productImage.previousButton.click(); + + await expect + .poll(async () => { + const src = await productImage.carouselImage.getAttribute('src'); + return src; + }) + .toContain(FIRST_IMAGE); + }); + + test('should not open the product', async ({page}) => { + expect(page.url()).toEqual(URL); + }); + }); + + test.describe('when clicking the indicator dot', () => { + test('should navigate to the corresponding image', async ({ + productImage, + }) => { + await expect + .poll(async () => { + const src = await productImage.carouselImage.getAttribute('src'); + return src; + }) + .toContain(FIRST_IMAGE); + + await productImage.indicatorDot.click(); + + await expect + .poll(async () => { + const src = await productImage.carouselImage.getAttribute('src'); + return src; + }) + .toContain(SECOND_IMAGE); + }); + }); +}); diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/e2e/fixture.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/e2e/fixture.ts index ca73e2a7976..7d1d031d2e3 100644 --- a/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/e2e/fixture.ts +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/e2e/fixture.ts @@ -15,4 +15,5 @@ export const test = base.extend({ await use(new ProductImageObject(page)); }, }); + export {expect} from '@playwright/test'; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/e2e/page-object.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/e2e/page-object.ts index 30a89aebb3a..063c75d6c6b 100644 --- a/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/e2e/page-object.ts +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-image/e2e/page-object.ts @@ -5,5 +5,57 @@ export class ProductImageObject extends BasePageObject<'atomic-product-image'> { constructor(page: Page) { super(page, 'atomic-product-image'); } - // TODO tests + + get noCarouselImage() { + return this.page.getByRole('img').nth(0); + } + + get carouselImage() { + return this.page.getByRole('img').nth(1); + } + + get nextButton() { + return this.page.getByRole('button', {name: 'Next'}); + } + + get previousButton() { + return this.page.getByRole('button', {name: 'Previous'}); + } + + get indicatorDot() { + return this.page.getByRole('listitem').nth(1); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async withCustomThumbnails(thumbnails: any[]) { + await this.page.route('**/commerce/v2/listing', async (route) => { + const response = await route.fetch(); + const body = await response.json(); + body.products[0].ec_thumbnails = thumbnails; + + await route.fulfill({ + response, + json: body, + }); + }); + return this; + } + + async withCustomField( + fieldNoCarousel: string | string[], + fieldCarousel: string | string[] + ) { + await this.page.route('**/commerce/v2/listing', async (route) => { + const response = await route.fetch(); + const body = await response.json(); + body.products[0].custom_alt_field = fieldNoCarousel; + body.products[1].custom_alt_field = fieldCarousel; + + await route.fulfill({ + response, + json: body, + }); + }); + return this; + } }