Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Adds test id to cards #2976

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 118 additions & 6 deletions src/cards/__tests__/cards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function findFooterPagination(wrapper: CardsWrapper): PaginationWrapper | null {

const cardDefinition: CardsProps.CardDefinition<Item> = {
header: item => `Header ${item.name}`,
testId: item => `card-${item.id}`,
sections: [
{
id: 'description',
Expand Down Expand Up @@ -59,14 +60,19 @@ describe('Cards', () => {
// index is 0-based
const getCard = (index: number) => wrapper.findItems()[index];

const getCardHeader = (index: number) => getCard(index)?.findCardHeader()?.getElement();
const getCardByTestId = (testId: string) => wrapper.findItemByTestId(testId)!;

const getCardHeader = (index: number) => wrapper.findItems()[index]?.findCardHeader()?.getElement();

// index is 0-based
const getCardsSections = (index: number) => getCard(index).findSections();
const getCardsSections = (index: number) => wrapper.findItems()[index].findSections();

// cardIndex and sectionIndex are 0-based
const getCardSection = (cardIndex: number, sectionIndex: number) => getCardsSections(cardIndex)[sectionIndex];

const getCardSectionByTestId = (cardIndex: number, sectionTestId: string) =>
getCard(cardIndex)!.findSectionByTestId(sectionTestId)!;

const getCardSectionHeader = (cardIndex: number, sectionIndex: number) =>
getCardSection(cardIndex, sectionIndex)?.findSectionHeader()?.getElement();

Expand All @@ -85,8 +91,7 @@ describe('Cards', () => {
wrapper = renderCards(
<Cards<Item> cardDefinition={cardDefinition} items={defaultItems} selectionType="single" />
).wrapper;

const cardsOrderedList = getCard(0).getElement().parentElement;
const cardsOrderedList = getCardByTestId('card-1').getElement().parentElement;
expect(cardsOrderedList).toHaveAttribute('role', 'group');
});

Expand Down Expand Up @@ -142,6 +147,7 @@ describe('Cards', () => {
sections: [
{
content: item => item.name,
testId: item => `card-${item.id}-content-section`,
},
],
}}
Expand All @@ -151,7 +157,8 @@ describe('Cards', () => {
defaultItems.forEach((item, idx) => {
expect(getCardsSections(idx)).toHaveLength(1);

expect(getCardSection(idx, 0).findSectionHeader()).toBe(null);
const sectionTestId = `card-${idx + 1}-content-section`;
expect(getCardSectionByTestId(idx, sectionTestId).findSectionHeader()).toBe(null);
expect(getCardSectionContent(idx, 0)).toHaveTextContent(item.name);
});
});
Expand All @@ -176,6 +183,36 @@ describe('Cards', () => {
expect(getCardSection(idx, 0)?.findContent()).toBe(null);
});
});

it('assigns test id attributes to the cards and sections', () => {
const { wrapper } = renderCards(
<Cards<Item>
cardDefinition={{
testId: item => `${item.name}-${item.id}`,
sections: [
{
testId: item => `${item.name}-${item.id}-id-section`,
header: 'id',
},
{
testId: item => `${item.name}-${item.id}-name-section`,
header: 'name',
},
],
}}
items={defaultItems}
/>
);

const itemTestIds = wrapper.findItems().map(item => item.getElement()!.getAttribute('data-testid'));
expect(itemTestIds).toEqual(['Apples-1', 'Oranges-2', 'Bananas-3']);

const secondItemSectionTestIds = wrapper
.findItems()[1]
.findSections()
.map(section => section.getElement()!.getAttribute('data-testid'));
expect(secondItemSectionTestIds).toEqual(['Oranges-2-id-section', 'Oranges-2-name-section']);
});
});

describe('header region', () => {
Expand Down Expand Up @@ -308,10 +345,85 @@ describe('Cards', () => {
<Cards<Item> cardDefinition={cardDefinition} selectionType="multi" items={defaultItems} />
</TestI18nProvider>
));
expect(getCard(0).findSelectionArea()!.getElement()).toHaveAttribute(
expect(getCardByTestId('card-1').findSelectionArea()!.getElement()).toHaveAttribute(
'aria-label',
expect.stringContaining('Custom label')
);
});
});

describe('test utils', () => {
it('findItemByTestId returns the card by test id', () => {
const { wrapper } = renderCards(
<Cards<Item>
cardDefinition={{
testId: item => `card-${item.id}`,
sections: [
{
content: item => item.name,
},
],
}}
items={defaultItems}
/>
);

expect(wrapper.findItemByTestId('card-2')!.getElement()).toHaveTextContent('Orange');
});

it('findItemByTestId returns the card even if test id contains quotes', () => {
const { wrapper } = renderCards(
<Cards<Item>
cardDefinition={{
testId: item => `card-"${item.id}"`,
}}
items={defaultItems}
/>
);

expect(wrapper.findItemByTestId('card-"2"')).toBeTruthy();
});

it('findSectionByTestId returns the section by test id', () => {
const { wrapper } = renderCards(
<Cards<Item>
cardDefinition={{
sections: [
{
content: item => `Item ID: ${item.id}`,
testId: item => `card-${item.id}-id`,
},
{
content: item => `Item Name: ${item.name}`,
testId: item => `card-${item.id}-name`,
},
],
}}
items={defaultItems}
/>
);

const firstItem = wrapper.findItems()[0]!;
expect(firstItem.findSectionByTestId('card-1-id')!.getElement()).toHaveTextContent('Item ID: 1');
expect(firstItem.findSectionByTestId('card-1-name')!.getElement()).toHaveTextContent('Item Name: Apples');
});

it('findSectionByTestId returns the section even if test id contains quotes', () => {
const { wrapper } = renderCards(
<Cards<Item>
cardDefinition={{
sections: [
{
testId: item => `card-section-"${item.id}"`,
},
],
}}
items={defaultItems}
/>
);

const firstItem = wrapper.findItems()[0]!;
expect(firstItem.findSectionByTestId('card-section-"1"')).toBeTruthy();
});
});
});
10 changes: 8 additions & 2 deletions src/cards/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ const CardsList = <T,>({
[styles['card-selected']]: selectable && isItemSelected(item),
})}
key={getItemKey(trackBy, item, index)}
data-testid={cardDefinition.testId?.(item)}
onFocus={onFocus}
{...(focusMarkers && focusMarkers.item)}
role={listItemRole}
Expand Down Expand Up @@ -301,8 +302,13 @@ const CardsList = <T,>({
</div>
)}
</div>
{visibleSectionsDefinition.map(({ width = 100, header, content, id }, index) => (
<div key={id || index} className={styles.section} style={{ width: `${width}%` }}>
{visibleSectionsDefinition.map(({ width = 100, header, content, id, testId }, index) => (
<div
key={id || index}
className={styles.section}
style={{ width: `${width}%` }}
data-testid={testId?.(item)}
>
{header ? <div className={styles['section-header']}>{header}</div> : ''}
{content ? <div className={styles['section-content']}>{content(item)}</div> : ''}
</div>
Expand Down
18 changes: 18 additions & 0 deletions src/cards/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,13 +223,31 @@ export namespace CardsProps {
export interface CardDefinition<T> {
header?(item: T): React.ReactNode;
sections?: ReadonlyArray<SectionDefinition<T>>;

/**
* Returns the test ID for each card item.
* Returned value is assigned to the `data-testid` attribute of the card's root element.
*
* @param {T} item Single item from the specified `items` array.
* @returns {string} Test id to be assigned to the corresponding card.
*/
testId?(item: T): string;
}

export interface SectionDefinition<T> {
id?: string;
header?: React.ReactNode;
content?(item: T): React.ReactNode;
width?: number;

/**
* Returns the test ID for each section item.
* Returned value is assigned to the `data-testid` attribute of the section's root element.
*
* @param {T} item Single item from the specified `items` array.
* @returns {string} Test id to be assigned to the corresponding section.
*/
testId?(item: T): string;
}

export interface CardsLayout {
Expand Down
27 changes: 27 additions & 0 deletions src/test-utils/dom/cards/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom';
import { escapeSelector } from '@cloudscape-design/test-utils-core/utils';

import CollectionPreferencesWrapper from '../collection-preferences';
import ContainerWrapper from '../container';
Expand Down Expand Up @@ -30,6 +31,19 @@ export class CardWrapper extends ComponentWrapper {
return this.findAllByClassName(styles.section).map(c => new CardSectionWrapper(c.getElement()));
}

/**
* Returns the wrapper of the first card section that matches the specified test ID.
* Looks for the `data-testid` attribute that is assigned via `sections.testId` prop.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, since I don't want to derail all the other PRs: why do we even have this line? From a builder perspective, all they care about is that they provided a testId and this util finds it based on what you provided. Why would I need to care about the fact that it's using data-testid?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data-testid is now part of our public API. This is for keeping the compatibility with RTL and other tools that look for data-testid.

This is actually one of the reasons why we decided to use this attribute rather than internal ones like data-awsui-id or others.

You can check the conversation in this PR or comments in related API proposal.

* If no matching card section is found, returns `null`.
*
* @param {string} testId
* @returns {CardSectionWrapper | null}
*/
findSectionByTestId(testId: string): CardSectionWrapper | null {
const escapedTestId = escapeSelector(testId);
return this.findComponent(`.${styles.section}[data-testid="${escapedTestId}"]`, CardSectionWrapper);
}

findCardHeader(): ElementWrapper | null {
return this.findByClassName(styles['card-header-inner']);
}
Expand All @@ -48,6 +62,19 @@ export default class CardsWrapper extends ComponentWrapper {
return this.findAllByClassName(styles.card).map(c => new CardWrapper(c.getElement()));
}

/**
* Returns the wrapper of the first card that matches the specified test ID.
* Looks for the `data-testid` attribute that is assigned via `cardDefinition.testId` prop.
* If no matching card is found, returns `null`.
*
* @param {string} testId
* @returns {CardWrapper | null}
*/
findItemByTestId(testId: string): CardWrapper | null {
const escapedTestId = escapeSelector(testId);
return this.findComponent(`.${styles.card}[data-testid="${escapedTestId}"]`, CardWrapper);
}

findSelectedItems(): Array<CardWrapper> {
return this.findAllByClassName(styles['card-selected']).map(c => new CardWrapper(c.getElement()));
}
Expand Down