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

FP-1650: Test Cases for _common/Button #640

Merged
merged 12 commits into from
Jun 14, 2022
Merged
Show file tree
Hide file tree
Changes from 4 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
58 changes: 29 additions & 29 deletions client/src/components/_common/Button/Button.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,22 @@ import Icon from '../Icon';
import styles from './Button.module.css';
import LoadingSpinner from '_common/LoadingSpinner';

export const TYPES = ['', 'primary', 'secondary', 'active', 'link'];
export const TYPE_MAP = {
primary: 'primary',
secondary: 'secondary',
tertiary: 'tertiary',
active: 'is-active',
link: 'as-link',
};
export const TYPES = [''].concat(Object.keys(TYPE_MAP));

export const SIZES = ['', 'short', 'medium', 'long', 'small'];
export const SIZE_MAP = {
short: 'width-short',
medium: 'width-medium',
long: 'width-long',
small: 'size-small',
};
export const SIZES = [''].concat(Object.keys(SIZE_MAP));

export const ATTRIBUTES = ['button', 'submit', 'reset'];

Expand All @@ -25,6 +38,7 @@ const Button = ({
iconNameAfter,
type,
size,
dataTestid,
Copy link
Member Author

@wesleyboar wesleyboar May 17, 2022

Choose a reason for hiding this comment

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

Extra: So that other elements can identify their <Button> instance in test cases.

disabled,
onClick,
attr,
Expand Down Expand Up @@ -65,38 +79,18 @@ const Button = ({
}
/* eslint-enable no-console */

const buttonRootClass = styles['root'];

let buttonTypeClass;
if (type === 'link') {
buttonTypeClass = styles['as-link'];
} else if (type === 'primary' || type === 'secondary' || type === 'active') {
buttonTypeClass = styles[`${type}`];
} else if (type === '') {
buttonTypeClass = type;
}

let buttonSizeClass;
if (size === 'small') {
buttonSizeClass = styles['size-small'];
} else {
buttonSizeClass = styles[`width-${size}`];
}

const buttonLoadingClass = isLoading ? styles['loading'] : '';

return (
<button
className={`
${buttonRootClass}
${buttonTypeClass}
${buttonSizeClass}
${buttonLoadingClass}
${className}
wesleyboar marked this conversation as resolved.
Show resolved Hide resolved
${styles['root']}
${TYPE_MAP[type] ? styles[TYPE_MAP[type]] : ''}
${SIZE_MAP[size] ? styles[SIZE_MAP[size]] : ''}
${isLoading ? styles['loading'] : ''}
`}
disabled={disabled || isLoading}
type={attr}
onClick={onclick}
data-testid={dataTestid}
>
{isLoading && (
<LoadingSpinner
Expand All @@ -107,15 +101,19 @@ const Button = ({
{iconNameBefore ? (
<Icon
name={iconNameBefore}
dataTestid="icon-before"
className={iconNameBefore ? styles['icon--before'] : ''}
/>
) : (
''
)}
<span className={styles['text']}>{children}</span>
<span className={styles['text']} data-testid="text">
{children}
</span>
{iconNameAfter && (
<Icon
name={iconNameAfter}
dataTestid="icon-after"
className={iconNameAfter ? styles['icon--after'] : ''}
/>
)}
Expand All @@ -124,11 +122,12 @@ const Button = ({
};
Button.propTypes = {
children: isNotEmptyString,
className: isNotEmptyString,
className: PropTypes.string,
iconNameBefore: PropTypes.string,
iconNameAfter: PropTypes.string,
type: PropTypes.oneOf(TYPES),
size: PropTypes.oneOf(SIZES),
dataTestid: PropTypes.string,
disabled: PropTypes.bool,
onClick: PropTypes.func,
attr: PropTypes.oneOf(ATTRIBUTES),
Expand All @@ -140,6 +139,7 @@ Button.defaultProps = {
iconNameAfter: '',
type: 'secondary',
size: '', // unless `type="link", defaults to `short` after `propTypes`
dataTestid: '',
wesleyboar marked this conversation as resolved.
Show resolved Hide resolved
disabled: false,
onClick: null,
attr: 'button',
Expand Down
177 changes: 164 additions & 13 deletions client/src/components/_common/Button/Button.test.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,171 @@
// WARNING: Relies on `Icon` because of `getByRole('img')`
import React from 'react';
import { render } from '@testing-library/react';
import Button from './Button';
import Button, * as BTN from './Button';

import '@testing-library/jest-dom/extend-expect';

const TEST_TEXT = '…';
const TEST_TYPE = 'primary';
const TEST_SIZE = 'medium';

function testClassnamesByType(type, size, getByRole, getByTestId) {
const root = getByRole('button');
const text = getByTestId('text');
const typeClassName = BTN.TYPE_MAP[type];
const sizeClassName = BTN.SIZE_MAP[size];
expect(root.className).toMatch('root');
expect(root.className).toMatch(new RegExp(typeClassName));
expect(root.className).toMatch(new RegExp(sizeClassName));
expect(text.className).toMatch('text');
}

function muteTypeNotLinkNoSizeLog(type, size) {
if (type !== 'link' && !size) console.debug = jest.fn();
}

function isPropertyLimitation(type, size) {
let isLimited = false;

if (
(type === 'primary' && size === 'small') ||
(type !== 'link' && !size) ||
(type === 'link' && size)
)
isLimited = true;

return isLimited;
}

function getExpectedType(type, size) {
let expType = type;
if (type === 'primary' && size === 'small') {
expType = '';
}
return expType;
}

describe('Button', () => {
it('does not render button without text', () => {
const { queryByTestId } = render(
<Button data-testid="no button here"></Button>
);
const el = queryByTestId('no button here');
expect(el).toBeNull;
wesleyboar marked this conversation as resolved.
Show resolved Hide resolved
it('uses given text', () => {
muteTypeNotLinkNoSizeLog();
const { getByTestId } = render(<Button>{TEST_TEXT}</Button>);
expect(getByTestId('text').textContent).toEqual(TEST_TEXT);
});

describe('icons exist as expected when', () => {
test('only `iconNameBefore` is given', () => {
muteTypeNotLinkNoSizeLog();
const { queryByTestId } = render(
<Button iconNameBefore="folder">{TEST_TEXT}</Button>
);
expect(queryByTestId('icon-before')).toBeInTheDocument();
expect(queryByTestId('icon-after')).not.toBeInTheDocument();
});
test('only `iconNameAfter` is given', () => {
muteTypeNotLinkNoSizeLog();
const { queryByTestId } = render(
<Button iconNameAfter="folder">{TEST_TEXT}</Button>
);
expect(queryByTestId('icon-before')).not.toBeInTheDocument();
expect(queryByTestId('icon-after')).toBeInTheDocument();
});
test('both `iconNameAfter` and `iconNameBefore` are given', () => {
muteTypeNotLinkNoSizeLog();
const { queryByTestId } = render(
<Button iconNameBefore="folder" iconNameAfter="file">
{TEST_TEXT}
</Button>
);
expect(queryByTestId('icon-before')).toBeInTheDocument();
expect(queryByTestId('icon-after')).toBeInTheDocument();
});
});
it('disables button when in loading state', () => {
const { queryByText } = render(
<Button isLoading={true}>Loading Button</Button>
);
const el = queryByText('Loading Button');
expect(el).toBeDisabled;
wesleyboar marked this conversation as resolved.
Show resolved Hide resolved

describe('all type & size combinations render accurately', () => {
it.each(BTN.TYPES)('type is "%s"', (type) => {
muteTypeNotLinkNoSizeLog();
if (isPropertyLimitation(type, TEST_SIZE)) {
return Promise.resolve();
}
Comment on lines +79 to +81
Copy link
Member Author

Choose a reason for hiding this comment

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

const { getByRole, getByTestId } = render(
<Button type={type} size={TEST_SIZE}>
{TEST_TEXT}
</Button>
);

testClassnamesByType(type, TEST_SIZE, getByRole, getByTestId);
});
it.each(BTN.SIZES)('size is "%s"', (size) => {
muteTypeNotLinkNoSizeLog();
if (isPropertyLimitation(TEST_TYPE, size)) {
return Promise.resolve();
}
const { getByRole, getByTestId } = render(
<Button type={TEST_TYPE} size={size}>
{TEST_TEXT}
</Button>
);

testClassnamesByType(TEST_TYPE, size, getByRole, getByTestId);
});
});

describe('loading', () => {
it('does not render button without text', () => {
muteTypeNotLinkNoSizeLog();
const { queryByTestId } = render(
<Button data-testid="no button here">{TEST_TEXT}</Button>
);
const el = queryByTestId('no button here');
expect(el).toBeNull;
});
it('disables button when in loading state', () => {
muteTypeNotLinkNoSizeLog();
const { queryByText } = render(
<Button isLoading={true}>Loading Button</Button>
);
const el = queryByText('Loading Button');
expect(el).toBeDisabled;
});
});

describe('property limitation', () => {
test('type is "link" & ANY size`', () => {
console.warn = jest.fn();
const { getByRole, getByTestId } = render(
<Button type="link" size={TEST_SIZE}>
{TEST_TEXT}
</Button>
);
const expectedType = 'link';
const expectedSize = '';

testClassnamesByType(expectedType, expectedSize, getByRole, getByTestId);
expect(console.warn).toHaveBeenCalled();
});
test('type is "primary" & size is "small"', () => {
console.error = jest.fn();
const { getByRole, getByTestId } = render(
<Button type="primary" size="small">
{TEST_TEXT}
</Button>
);
const expectedType = 'secondary';
const expectedSize = 'small';

testClassnamesByType(expectedType, expectedSize, getByRole, getByTestId);
expect(console.error).toHaveBeenCalled();
});
test('type is not "link" & NO size`', () => {
console.debug = jest.fn();
const { getByRole, getByTestId } = render(
<Button type="primary">{TEST_TEXT}</Button>
);
const expectedType = 'primary';
const expectedSize = 'short';

testClassnamesByType(expectedType, expectedSize, getByRole, getByTestId);
expect(console.debug).toHaveBeenCalled();
});
});
});
14 changes: 12 additions & 2 deletions client/src/components/_common/Icon/Icon.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,37 @@ import React from 'react';
import PropTypes from 'prop-types';
import './Icon.module.css';

const Icon = ({ children, className, name }) => {
const Icon = ({ children, className, dataTestid, name }) => {
Copy link
Member Author

Choose a reason for hiding this comment

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

Explanation: So <Button /> tests can distinguish between the two icons it can (simultaneously) have.

const iconClassName = `icon icon-${name}`;
// FAQ: The conditional avoids an extra space in class attribute value
const fullClassName = className
? [className, iconClassName].join(' ')
: iconClassName;
const label = children;

return <i className={fullClassName} role="img" aria-label={label} />;
return (
<i
className={fullClassName}
role="img"
aria-label={label}
data-testid={dataTestid}
/>
);
};
Icon.propTypes = {
/** A text alternative to the icon (for accessibility) */
children: PropTypes.string,
/** Additional className for the root element */
className: PropTypes.string,
/** ID for test case element selection */
dataTestid: PropTypes.string,
/** Name of icon from icon font (without the (`icon-` prefix) */
name: PropTypes.string.isRequired,
};
Icon.defaultProps = {
children: '',
className: '',
dataTestid: '',
};

export default Icon;