Move shlink-frontend-kit tests to its own dir

This commit is contained in:
Alejandro Celaya
2023-08-02 08:15:50 +02:00
parent 99ce8c9f74
commit b7d57a53f2
17 changed files with 43 additions and 32 deletions

View File

@@ -2,10 +2,10 @@ import type { ReactNode } from 'react';
import type { CardProps } from 'reactstrap';
import { Card, CardBody, CardHeader } from 'reactstrap';
interface SimpleCardProps extends Omit<CardProps, 'title'> {
export type SimpleCardProps = Omit<CardProps, 'title'> & {
title?: ReactNode;
bodyClassName?: string;
}
};
export const SimpleCard = ({ title, children, bodyClassName, ...rest }: SimpleCardProps) => (
<Card {...rest}>

View File

@@ -10,9 +10,9 @@ export type BooleanControlProps = PropsWithChildren<{
inline?: boolean;
}>;
interface BooleanControlWithTypeProps extends BooleanControlProps {
type BooleanControlWithTypeProps = BooleanControlProps & {
type: 'switch' | 'checkbox';
}
};
export const BooleanControl: FC<BooleanControlWithTypeProps> = (
{ checked = false, onChange = identity, className, children, type, inline = false },

View File

@@ -7,13 +7,13 @@ import './SearchField.scss';
const DEFAULT_SEARCH_INTERVAL = 500;
let timer: NodeJS.Timeout | null;
interface SearchFieldProps {
type SearchFieldProps = {
onChange: (value: string) => void;
className?: string;
large?: boolean;
noBorder?: boolean;
initialValue?: string;
}
};
export const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
const [searchTerm, setSearchTerm] = useState(initialValue);

View File

@@ -7,14 +7,14 @@ import type { Order, OrderDir } from './ordering';
import { determineOrderDir } from './ordering';
import './OrderingDropdown.scss';
export interface OrderingDropdownProps<T extends string = string> {
export type OrderingDropdownProps<T extends string = string> = {
items: Record<T, string>;
order: Order<T>;
onChange: (orderField?: T, orderDir?: OrderDir) => void;
isButton?: boolean;
right?: boolean;
prefixed?: boolean;
}
};
export function OrderingDropdown<T extends string = string>(
{ items, order, onChange, isButton = true, right = false, prefixed = true }: OrderingDropdownProps<T>,

View File

@@ -1,9 +1,9 @@
export type OrderDir = 'ASC' | 'DESC' | undefined;
export interface Order<Fields> {
export type Order<Fields> = {
field?: Fields;
dir?: OrderDir;
}
};
export const determineOrderDir = <T extends string = string>(
currentField: T,

View File

@@ -0,0 +1,8 @@
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event/index';
import type { ReactElement } from 'react';
export const renderWithEvents = (element: ReactElement) => ({
user: userEvent.setup(),
...render(element),
});

View File

@@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react';
import type { PropsWithChildren } from 'react';
import type { MessageProps } from '../../src';
import { Message } from '../../src';
describe('<Message />', () => {
const setUp = (props: PropsWithChildren<MessageProps> = {}) => render(<Message {...props} />);
it.each([
[true, 'col-md-12'],
[false, 'col-md-10 offset-md-1'],
[undefined, 'col-md-10 offset-md-1'],
])('renders expected classes based on width', (fullWidth, expectedClass) => {
const { container } = setUp({ fullWidth });
expect(container.firstChild?.firstChild).toHaveClass(expectedClass);
});
it.each([
[true, 'These are the children contents'],
[false, 'These are the children contents'],
[true, undefined],
[false, undefined],
])('renders expected content', (loading, children) => {
setUp({ loading, children });
expect(screen.queryAllByRole('img', { hidden: true })).toHaveLength(loading ? 1 : 0);
if (loading) {
expect(screen.getByText(children || 'Loading...')).toHaveClass('ms-2');
} else {
expect(screen.getByRole('heading')).toHaveTextContent(children || '');
}
});
it.each([
['error', 'border-danger', 'text-danger'],
['default', '', 'text-muted'],
[undefined, '', 'text-muted'],
])('renders proper classes based on message type', (type, expectedCardClass, expectedH3Class) => {
const { container } = setUp({ type: type as 'default' | 'error' | undefined });
expect(container.querySelector('.card-body')).toHaveAttribute('class', expect.stringContaining(expectedCardClass));
expect(screen.getByRole('heading')).toHaveClass(`text-center mb-0 ${expectedH3Class}`);
});
it.each([{ className: 'foo' }, { className: 'bar' }, {}])('renders provided classes', ({ className }) => {
const { container } = setUp({ className });
expect(container.firstChild).toHaveClass(`g-0${className ? ` ${className}` : ''}`);
});
});

View File

@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import type { ResultProps, ResultType } from '../../src';
import { Result } from '../../src';
describe('<Result />', () => {
const setUp = (props: ResultProps) => render(<Result {...props} />);
it.each([
['success' as ResultType, 'bg-main text-white'],
['error' as ResultType, 'bg-danger text-white'],
['warning' as ResultType, 'bg-warning'],
])('renders expected classes based on type', (type, expectedClasses) => {
setUp({ type });
expect(screen.getByRole('document')).toHaveClass(expectedClasses);
});
it.each([
['foo'],
['bar'],
])('renders provided classes in root element', (className) => {
const { container } = setUp({ type: 'success', className });
expect(container.firstChild).toHaveClass(className);
});
it.each([{ small: true }, { small: false }])('renders small results properly', ({ small }) => {
const { container } = setUp({ type: 'success', small });
const bigElement = container.querySelectorAll('.col-md-10');
const smallElement = container.querySelectorAll('.col-12');
expect(bigElement).toHaveLength(small ? 0 : 1);
expect(smallElement).toHaveLength(small ? 1 : 0);
});
});

View File

@@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react';
import type { SimpleCardProps } from '../../src';
import { SimpleCard } from '../../src';
const setUp = ({ children, ...rest }: SimpleCardProps = {}) => render(<SimpleCard {...rest}>{children}</SimpleCard>);
describe('<SimpleCard />', () => {
it('does not render title if not provided', () => {
setUp();
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
it('renders provided title', () => {
setUp({ title: 'Cool title' });
expect(screen.getByRole('heading')).toHaveTextContent('Cool title');
});
it('renders children inside body', () => {
setUp({ children: 'Hello world' });
expect(screen.getByText('Hello world')).toBeInTheDocument();
});
it.each(['primary', 'danger', 'warning'])('passes extra props to nested card', (color) => {
const { container } = setUp({ className: 'foo', color, children: 'Hello world' });
expect(container.firstChild).toHaveAttribute('class', `foo card bg-${color}`);
});
});

View File

@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react';
import { Checkbox } from '../../src';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<Checkbox />', () => {
it.each([['foo'], ['bar'], ['baz']])('includes extra class names when provided', (className) => {
const { container } = render(<Checkbox className={className} />);
expect(container.firstChild).toHaveAttribute('class', `form-check form-checkbox ${className}`);
});
it.each([[true], [false]])('marks input as checked if defined', (checked) => {
render(<Checkbox checked={checked}>Foo</Checkbox>);
if (checked) {
expect(screen.getByLabelText('Foo')).toBeChecked();
} else {
expect(screen.getByLabelText('Foo')).not.toBeChecked();
}
});
it.each([['foo'], ['bar'], ['baz']])('renders provided children inside the label', (children) => {
render(<Checkbox>{children}</Checkbox>);
expect(screen.getByText(children)).toHaveAttribute('class', 'form-check-label');
});
it.each([[true], [false]])('changes checked status on input change', async (checked) => {
const onChange = vi.fn();
const { user } = renderWithEvents(<Checkbox onChange={onChange} checked={checked}>Foo</Checkbox>);
expect(onChange).not.toHaveBeenCalled();
await user.click(screen.getByLabelText('Foo'));
expect(onChange).toHaveBeenCalledWith(!checked, expect.anything());
});
it.each([[true], [false]])('allows setting inline rendering', (inline) => {
const { container } = render(<Checkbox inline={inline} />);
if (inline) {
expect(container.firstChild).toHaveAttribute('style', 'display: inline-block;');
} else {
expect(container.firstChild).not.toHaveAttribute('style');
}
});
});

View File

@@ -0,0 +1,47 @@
import { screen } from '@testing-library/react';
import type { PropsWithChildren } from 'react';
import type { DropdownBtnProps } from '../../src';
import { DropdownBtn } from '../../src';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<DropdownBtn />', () => {
const setUp = (props: PropsWithChildren<DropdownBtnProps>) => renderWithEvents(
<DropdownBtn children="foo" {...props} />,
);
it.each([['foo'], ['bar'], ['baz']])('displays provided text in button', (text) => {
setUp({ text });
expect(screen.getByRole('button')).toHaveTextContent(text);
});
it.each([['foo'], ['bar'], ['baz']])('displays provided children in menu', async (children) => {
const { user } = setUp({ text: '', children });
await user.click(screen.getByRole('button'));
expect(screen.getByRole('menu')).toHaveTextContent(children);
});
it.each([
[undefined, 'dropdown-btn__toggle btn-block'],
['', 'dropdown-btn__toggle btn-block'],
['foo', 'dropdown-btn__toggle btn-block foo'],
['bar', 'dropdown-btn__toggle btn-block bar'],
])('includes provided classes', (className, expectedClasses) => {
setUp({ text: '', className });
expect(screen.getByRole('button')).toHaveClass(expectedClasses);
});
it.each([
[100, 'min-width: 100px; '],
[250, 'min-width: 250px; '],
[undefined, ''],
])('renders proper styles when minWidth is provided', async (minWidth, expectedStyle) => {
const { user } = setUp({ text: '', minWidth });
await user.click(screen.getByRole('button'));
expect(screen.getByRole('menu')).toHaveAttribute(
'style',
`${expectedStyle}position: absolute; left: 0px; top: 0px; transform: translate(0px, 0px);`,
);
});
});

View File

@@ -0,0 +1,56 @@
/* eslint-disable no-console */
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { NavPillItem, NavPills } from '../../src';
describe('<NavPills />', () => {
let originalError: typeof console.error;
beforeEach(() => {
originalError = console.error;
console.error = () => {}; // Suppress errors logged during this test
});
afterEach(() => {
console.error = originalError;
});
it.each([
['Foo'],
[<span key="1">Hi!</span>],
[[<NavPillItem key="1" to="" />, <span key="2">Hi!</span>]],
])('throws error when any of the children is not a NavPillItem', (children) => {
expect.assertions(1);
expect(() => render(<NavPills>{children}</NavPills>)).toThrow(
'Only NavPillItem children are allowed inside NavPills.',
);
});
it.each([
[undefined],
[true],
[false],
])('renders provided items', (fill) => {
const { container } = render(
<MemoryRouter>
<NavPills fill={fill}>
<NavPillItem to="1">1</NavPillItem>
<NavPillItem to="2">2</NavPillItem>
<NavPillItem to="3">3</NavPillItem>
</NavPills>
</MemoryRouter>,
);
const links = screen.getAllByRole('link');
expect(links).toHaveLength(3);
links.forEach((link, index) => {
expect(link).toHaveTextContent(`${index + 1}`);
expect(link).toHaveAttribute('href', `/${index + 1}`);
});
if (fill) {
expect(container.querySelector('.nav')).toHaveClass('nav-fill');
} else {
expect(container.querySelector('.nav')).not.toHaveClass('nav-fill');
}
});
});

View File

@@ -0,0 +1,28 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { DropdownBtnMenuProps } from '../../src';
import { RowDropdownBtn } from '../../src';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<RowDropdownBtn />', () => {
const setUp = (props: Partial<DropdownBtnMenuProps> = {}) => renderWithEvents(
<RowDropdownBtn {...fromPartial<DropdownBtnMenuProps>({ ...props })}>
the children
</RowDropdownBtn>,
);
it('renders expected components', () => {
setUp();
const toggle = screen.getByRole('button');
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveClass('btn-sm');
expect(toggle).toHaveClass('dropdown-btn__toggle');
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
});
it('renders expected children', () => {
setUp();
expect(screen.getByText('the children')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,107 @@
import { screen } from '@testing-library/react';
import { values } from 'ramda';
import type { OrderDir, OrderingDropdownProps } from '../../src';
import { OrderingDropdown } from '../../src';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<OrderingDropdown />', () => {
const items = {
foo: 'Foo',
bar: 'Bar',
baz: 'Hello World',
};
const setUp = (props: Partial<OrderingDropdownProps> = {}) => renderWithEvents(
<OrderingDropdown items={items} order={{}} onChange={vi.fn()} {...props} />,
);
const setUpWithDisplayedMenu = async (props: Partial<OrderingDropdownProps> = {}) => {
const result = setUp(props);
const { user } = result;
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('menu')).toBeInTheDocument();
return result;
};
it('properly renders provided list of items', async () => {
await setUpWithDisplayedMenu();
const dropdownItems = screen.getAllByRole('menuitem');
expect(dropdownItems).toHaveLength(values(items).length);
expect(dropdownItems[0]).toHaveTextContent('Foo');
expect(dropdownItems[1]).toHaveTextContent('Bar');
expect(dropdownItems[2]).toHaveTextContent('Hello World');
expect(screen.getByRole('button', { name: 'Clear selection' })).toBeInTheDocument();
});
it.each([
['foo', 0],
['bar', 1],
['baz', 2],
])('properly marks selected field as active with proper icon', async (field, expectedActiveIndex) => {
await setUpWithDisplayedMenu({ order: { field, dir: 'DESC' } });
const dropdownItems = screen.getAllByRole('menuitem');
expect(dropdownItems).toHaveLength(4);
expect(screen.queryByRole('button', { name: 'Clear selection' })).not.toBeInTheDocument();
dropdownItems.forEach((item, index) => {
if (index === expectedActiveIndex) {
expect(item).toHaveAttribute('class', expect.stringContaining('active'));
} else {
expect(item).not.toHaveAttribute('class', expect.stringContaining('active'));
}
});
});
it.each([
[{} as any, 'foo', 'ASC'],
[{ field: 'baz', dir: 'ASC' } as any, 'foo', 'ASC'],
[{ field: 'foo', dir: 'ASC' } as any, 'foo', 'DESC'],
[{ field: 'foo', dir: 'DESC' } as any, undefined, undefined],
])(
'triggers change with proper params depending on clicked item and initial state',
async (initialOrder, expectedNewField, expectedNewDir) => {
const onChange = vi.fn();
const { user } = await setUpWithDisplayedMenu({ onChange, order: initialOrder });
await user.click(screen.getAllByRole('menuitem')[0]);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expectedNewField, expectedNewDir);
},
);
it('clears selection when last item is clicked', async () => {
const onChange = vi.fn();
const { user } = await setUpWithDisplayedMenu({ onChange, order: { field: 'baz', dir: 'ASC' } });
await user.click(screen.getAllByRole('menuitem')[3]);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith();
});
it.each([
[{ isButton: false }, /Order by$/],
[{ isButton: true }, 'Order by...'],
[
{ isButton: true, order: { field: 'foo', dir: 'ASC' as OrderDir } },
'Order by: Foo - ASC',
],
[
{ isButton: true, order: { field: 'baz', dir: 'DESC' as OrderDir } },
'Order by: Hello World - DESC',
],
[{ isButton: true, order: { field: 'baz' } }, 'Order by: Hello World - DESC'],
[
{ isButton: true, order: { field: 'baz', dir: 'DESC' as OrderDir }, prefixed: false },
/^Hello World - DESC/,
],
])('with %s props displays %s in toggle', async (props, expectedText) => {
setUp(props);
expect(screen.getByRole('button')).toHaveTextContent(expectedText);
});
});

View File

@@ -0,0 +1,46 @@
import type { OrderDir } from '../../src';
import { determineOrderDir, orderToString, stringToOrder } from '../../src';
describe('ordering', () => {
describe('determineOrderDir', () => {
it('returns ASC when current order field and selected field are different', () => {
expect(determineOrderDir('foo', 'bar')).toEqual('ASC');
expect(determineOrderDir('bar', 'foo')).toEqual('ASC');
});
it('returns ASC when no current order dir is provided', () => {
expect(determineOrderDir('foo', 'foo')).toEqual('ASC');
expect(determineOrderDir('bar', 'bar')).toEqual('ASC');
});
it('returns DESC when current order field and selected field are equal and current order dir is ASC', () => {
expect(determineOrderDir('foo', 'foo', 'ASC')).toEqual('DESC');
expect(determineOrderDir('bar', 'bar', 'ASC')).toEqual('DESC');
});
it('returns undefined when current order field and selected field are equal and current order dir is DESC', () => {
expect(determineOrderDir('foo', 'foo', 'DESC')).toBeUndefined();
expect(determineOrderDir('bar', 'bar', 'DESC')).toBeUndefined();
});
});
describe('orderToString', () => {
it.each([
[{}, undefined],
[{ field: 'foo' }, undefined],
[{ field: 'foo', dir: 'ASC' as OrderDir }, 'foo-ASC'],
[{ field: 'bar', dir: 'DESC' as OrderDir }, 'bar-DESC'],
])('casts the order to string', (order, expectedResult) => {
expect(orderToString(order)).toEqual(expectedResult);
});
});
describe('stringToOrder', () => {
it.each([
['foo-ASC', { field: 'foo', dir: 'ASC' }],
['bar-DESC', { field: 'bar', dir: 'DESC' }],
])('casts a string to an order objects', (order, expectedResult) => {
expect(stringToOrder(order)).toEqual(expectedResult);
});
});
});