This commit is contained in:
71
tests/components/Button.test.tsx
Normal file
71
tests/components/Button.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { HomeIcon } from '@heroicons/react/24/solid';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Button } from '../../src/components/Button';
|
||||
import { renderWithRouter } from '../helpers/renderWithRouter';
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders native button with expected type and disabled state', () => {
|
||||
render(<Button label="Save" type="solid" htmlType="submit" disabled />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Save' });
|
||||
expect(button.tagName).toBe('BUTTON');
|
||||
expect(button).toHaveAttribute('type', 'submit');
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveClass('btn-solid');
|
||||
expect(button).toHaveClass('btn-primary');
|
||||
});
|
||||
|
||||
it('defaults non-solid button variants to secondary', () => {
|
||||
render(<Button label="Details" type="noborder" />);
|
||||
expect(screen.getByRole('button', { name: 'Details' })).toHaveClass('btn-secondary');
|
||||
});
|
||||
|
||||
it('renders icon-only button and custom aria label', () => {
|
||||
render(<Button type="solid" icon={HomeIcon} ariaLabel="Open home" />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Open home' });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('!p-0');
|
||||
expect(button.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('renders link button and prevents click when disabled', () => {
|
||||
const onClick = vi.fn();
|
||||
renderWithRouter(
|
||||
<Button label="Go home" type="outlined" to="/home" onClick={onClick} disabled />,
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Go home' });
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(link).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(link).toHaveAttribute('tabindex', '-1');
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClick for enabled link buttons', () => {
|
||||
const onClick = vi.fn();
|
||||
renderWithRouter(<Button label="Profile" type="solid" to="/profile" onClick={onClick} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Profile' }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders icon+label spacing and custom class names', () => {
|
||||
render(
|
||||
<Button
|
||||
label="Home"
|
||||
type="outlined"
|
||||
icon={HomeIcon}
|
||||
className="custom-button"
|
||||
width="full"
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Home' });
|
||||
expect(button).toHaveClass('gap-1.5');
|
||||
expect(button).toHaveClass('custom-button');
|
||||
expect(button).toHaveClass('w-full');
|
||||
});
|
||||
});
|
||||
54
tests/components/Chip.test.tsx
Normal file
54
tests/components/Chip.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Chip } from '../../src/components/Chip';
|
||||
|
||||
describe('Chip', () => {
|
||||
it('renders default span with solid classes', () => {
|
||||
render(<Chip>Default</Chip>);
|
||||
|
||||
const chip = screen.getByText('Default');
|
||||
expect(chip.tagName).toBe('SPAN');
|
||||
expect(chip.className).toContain('chip-solid');
|
||||
});
|
||||
|
||||
it('supports outlined variant with valid tone', () => {
|
||||
render(
|
||||
<Chip as="div" variant="outlined" tone="indigo-700">
|
||||
Custom
|
||||
</Chip>,
|
||||
);
|
||||
|
||||
const chip = screen.getByText('Custom');
|
||||
expect(chip.tagName).toBe('DIV');
|
||||
expect(chip.className).toContain('chip-outlined');
|
||||
expect(chip).toHaveStyle({
|
||||
borderColor: 'rgb(67, 56, 202)',
|
||||
color: 'rgb(67, 56, 202)',
|
||||
});
|
||||
});
|
||||
|
||||
it('supports direct tone tokens without shades for solid variant', () => {
|
||||
render(<Chip tone="white">Solid</Chip>);
|
||||
|
||||
expect(screen.getByText('Solid')).toHaveStyle({
|
||||
borderColor: 'rgb(255, 255, 255)',
|
||||
backgroundColor: 'rgb(255, 255, 255)',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores invalid/empty tones and keeps className', () => {
|
||||
const { rerender } = render(
|
||||
<Chip tone="not-a-token" className="chip-custom">
|
||||
Invalid
|
||||
</Chip>,
|
||||
);
|
||||
|
||||
const invalid = screen.getByText('Invalid');
|
||||
expect(invalid).toHaveClass('chip-custom');
|
||||
expect(invalid.getAttribute('style')).toBeNull();
|
||||
|
||||
rerender(<Chip tone=" ">Blank</Chip>);
|
||||
expect(screen.getByText('Blank').getAttribute('style')).toBeNull();
|
||||
});
|
||||
});
|
||||
62
tests/components/DatePicker.test.tsx
Normal file
62
tests/components/DatePicker.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DatePicker } from '../../src/components/DatePicker';
|
||||
|
||||
describe('DatePicker', () => {
|
||||
it('supports datetime-local type and change callback', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<DatePicker label="Schedule" type="datetime-local" value="" onChange={onChange} />);
|
||||
|
||||
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||
expect(input.type).toBe('datetime-local');
|
||||
|
||||
fireEvent.change(input, { target: { value: '2031-05-20T14:30' } });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports date type and disabled state', () => {
|
||||
render(
|
||||
<DatePicker
|
||||
label="Publish date"
|
||||
type="date"
|
||||
value="2031-05-20"
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText('Publish date') as HTMLInputElement;
|
||||
expect(input.type).toBe('date');
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders right icon and error message', () => {
|
||||
const { container } = render(
|
||||
<DatePicker
|
||||
label="Schedule"
|
||||
type="datetime-local"
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
rightIcon={<span data-testid="right-icon">R</span>}
|
||||
error="Invalid date"
|
||||
inputClassName="custom-input"
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = container.querySelector('input');
|
||||
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||
expect(screen.getByTestId('right-icon')).toBeInTheDocument();
|
||||
expect(input).toHaveClass('pr-10');
|
||||
expect(input).toHaveClass('custom-input');
|
||||
expect(screen.getByText('Invalid date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports inline layout', () => {
|
||||
const { container } = render(
|
||||
<DatePicker label="Start time" type="time" value="09:00" onChange={() => {}} layout="inline" />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('label')).toHaveClass('inline-flex');
|
||||
expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
|
||||
});
|
||||
});
|
||||
55
tests/components/Dropdown.test.tsx
Normal file
55
tests/components/Dropdown.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Dropdown } from '../../src/components/Dropdown';
|
||||
|
||||
const choices = [
|
||||
{ id: 'USER', label: 'User' },
|
||||
{ id: 'ADMIN', label: 'Admin' },
|
||||
];
|
||||
|
||||
describe('Dropdown', () => {
|
||||
it('calls onChange with selected value', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Dropdown label="Role" value="USER" choices={choices} onChange={onChange} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Role'), { target: { value: 'ADMIN' } });
|
||||
expect(onChange).toHaveBeenCalledWith('ADMIN');
|
||||
});
|
||||
|
||||
it('supports inline layout and disabled/required state', () => {
|
||||
const { container } = render(
|
||||
<Dropdown
|
||||
label="Rows"
|
||||
value="10"
|
||||
choices={[{ id: '10', label: '10' }]}
|
||||
layout="inline"
|
||||
disabled
|
||||
required
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText('Rows');
|
||||
expect(select).toBeDisabled();
|
||||
expect(select).toBeRequired();
|
||||
expect(container.querySelector('label')).toHaveClass('inline-flex');
|
||||
});
|
||||
|
||||
it('renders error and custom class names', () => {
|
||||
const { container } = render(
|
||||
<Dropdown
|
||||
label="Role"
|
||||
value="USER"
|
||||
choices={choices}
|
||||
error="Role is invalid"
|
||||
className="custom-wrapper"
|
||||
selectClassName="custom-select"
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = container.querySelector('select');
|
||||
expect(select).toBeInstanceOf(HTMLSelectElement);
|
||||
expect(screen.getByText('Role is invalid')).toBeInTheDocument();
|
||||
expect(select).toHaveClass('custom-select');
|
||||
expect(screen.getByText('Role').closest('label')).toHaveClass('custom-wrapper');
|
||||
});
|
||||
});
|
||||
28
tests/components/Form.test.tsx
Normal file
28
tests/components/Form.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Form } from '../../src/components/Form';
|
||||
|
||||
describe('Form', () => {
|
||||
it('renders title, title actions and children', () => {
|
||||
render(
|
||||
<Form title="User Details" titleBarRight={<button type="button">Action</button>}>
|
||||
<div>Form child</div>
|
||||
</Form>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('User Details')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Form child')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports custom class names and optional title actions', () => {
|
||||
const { container } = render(
|
||||
<Form title="No Actions" className="form-custom">
|
||||
<div>Child</div>
|
||||
</Form>,
|
||||
);
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('form-custom');
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
92
tests/components/InputField.test.tsx
Normal file
92
tests/components/InputField.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createRef } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { InputField } from '../../src/components/InputField';
|
||||
|
||||
describe('InputField', () => {
|
||||
it('supports email type and emits change/blur callbacks', () => {
|
||||
const onChange = vi.fn();
|
||||
const onBlur = vi.fn();
|
||||
render(
|
||||
<InputField
|
||||
label="Email"
|
||||
type="email"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
required
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText('Email') as HTMLInputElement;
|
||||
expect(input.type).toBe('email');
|
||||
expect(input).toBeRequired();
|
||||
|
||||
fireEvent.change(input, { target: { value: 'new@example.com' } });
|
||||
fireEvent.blur(input);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onBlur).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes refs to input element', () => {
|
||||
const inputRef = createRef<HTMLInputElement>();
|
||||
render(<InputField label="Username" type="text" value="john" onChange={() => {}} inputRef={inputRef} />);
|
||||
|
||||
expect(inputRef.current).toBeInstanceOf(HTMLInputElement);
|
||||
expect(inputRef.current?.value).toBe('john');
|
||||
});
|
||||
|
||||
it('toggles password visibility', () => {
|
||||
render(<InputField label="Password" type="password" value="abc" onChange={() => {}} />);
|
||||
|
||||
expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('password');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show password' }));
|
||||
expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('text');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide password' }));
|
||||
expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('password');
|
||||
});
|
||||
|
||||
it('renders rightIcon for non-password input and displays errors', () => {
|
||||
const { container } = render(
|
||||
<InputField
|
||||
label="Username"
|
||||
type="text"
|
||||
value="john"
|
||||
onChange={() => {}}
|
||||
rightIcon={<span data-testid="right-icon">R</span>}
|
||||
error="Invalid username"
|
||||
inputClassName="custom-input"
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = container.querySelector('input');
|
||||
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||
expect(input).toHaveClass('pr-10');
|
||||
expect(input).toHaveClass('custom-input');
|
||||
expect(screen.getByTestId('right-icon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Invalid username')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables password toggle when input is disabled', () => {
|
||||
render(
|
||||
<InputField
|
||||
label="Password"
|
||||
type="password"
|
||||
value="secret"
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Show password' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('supports inline layout classes', () => {
|
||||
const { container } = render(
|
||||
<InputField label="Username" type="text" value="" onChange={() => {}} layout="inline" />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('label')).toHaveClass('inline-flex');
|
||||
expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
|
||||
});
|
||||
});
|
||||
25
tests/components/Label.test.tsx
Normal file
25
tests/components/Label.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Label } from '../../src/components/Label';
|
||||
|
||||
describe('Label', () => {
|
||||
it('uses default and variant-specific tags/classes', () => {
|
||||
const { rerender } = render(<Label>Body</Label>);
|
||||
expect(screen.getByText('Body').tagName).toBe('P');
|
||||
expect(screen.getByText('Body')).toHaveClass('ui-body-primary');
|
||||
|
||||
rerender(<Label variant="h1">Title</Label>);
|
||||
expect(screen.getByText('Title').tagName).toBe('H1');
|
||||
expect(screen.getByText('Title')).toHaveClass('ui-title');
|
||||
|
||||
rerender(<Label variant="h4">Section</Label>);
|
||||
expect(screen.getByText('Section').tagName).toBe('H3');
|
||||
|
||||
rerender(
|
||||
<Label variant="h2" as="span">
|
||||
Custom
|
||||
</Label>,
|
||||
);
|
||||
expect(screen.getByText('Custom').tagName).toBe('SPAN');
|
||||
});
|
||||
});
|
||||
82
tests/components/MDXEditorField.test.tsx
Normal file
82
tests/components/MDXEditorField.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MDXEditorField } from '../../src/components/MDXEditorField';
|
||||
|
||||
describe('MDXEditorField', () => {
|
||||
it('renders label and change handler in editable mode', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<MDXEditorField
|
||||
label="Content"
|
||||
markdown=""
|
||||
onChange={onChange}
|
||||
themeClassName="light-theme"
|
||||
editorClassName="extra-editor"
|
||||
plugins={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Content')).toHaveClass('ui-label');
|
||||
fireEvent.change(screen.getByLabelText('Markdown Editor'), {
|
||||
target: { value: '# Hello' },
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith('# Hello');
|
||||
expect(screen.getByLabelText('Markdown Editor')).toHaveAttribute(
|
||||
'data-class-name',
|
||||
'light-theme extra-editor',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders preview and disabled classes when disabled', () => {
|
||||
render(
|
||||
<MDXEditorField
|
||||
label="Content"
|
||||
markdown="Disabled content"
|
||||
disabled
|
||||
themeClassName="dark-theme"
|
||||
plugins={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const label = screen.getByText('Content');
|
||||
expect(label).toHaveClass('ui-label-disabled');
|
||||
expect(screen.getByTestId('md-preview')).toHaveTextContent('Disabled content');
|
||||
expect(screen.getByTestId('md-preview')).toHaveAttribute('data-class-name', 'dark-theme');
|
||||
expect(screen.queryByLabelText('Markdown Editor')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.post-mdx-editor--disabled')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders read-only preview without label when label is omitted', () => {
|
||||
render(
|
||||
<MDXEditorField
|
||||
markdown="Read only content"
|
||||
readOnly
|
||||
themeClassName="dark-theme"
|
||||
plugins={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Content')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('md-preview')).toHaveTextContent('Read only content');
|
||||
});
|
||||
|
||||
it('supports wrapper style/class overrides and error rendering', () => {
|
||||
render(
|
||||
<MDXEditorField
|
||||
label="Content"
|
||||
markdown=""
|
||||
themeClassName="light-theme"
|
||||
plugins={[]}
|
||||
editorWrapperClassName="editor-wrapper"
|
||||
editorWrapperStyle={{ borderWidth: '2px' }}
|
||||
error="Content is required"
|
||||
/>,
|
||||
);
|
||||
|
||||
const wrapper = document.querySelector('.editor-wrapper');
|
||||
expect(wrapper).toHaveClass('post-mdx-editor--enabled');
|
||||
expect(wrapper).toHaveStyle({ borderWidth: '2px' });
|
||||
expect(screen.getByText('Content is required')).toHaveClass('ui-error');
|
||||
});
|
||||
});
|
||||
55
tests/components/SidebarNavItem.test.tsx
Normal file
55
tests/components/SidebarNavItem.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { UserCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SidebarNavItem } from '../../src/components/SidebarNavItem';
|
||||
import { renderWithRouter } from '../helpers/renderWithRouter';
|
||||
|
||||
describe('SidebarNavItem', () => {
|
||||
it('renders active style and collapsed label behavior', () => {
|
||||
renderWithRouter(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/users"
|
||||
element={<SidebarNavItem to="/users" label="Users" icon={UserCircleIcon} collapsed />}
|
||||
/>
|
||||
</Routes>,
|
||||
{ route: '/users' },
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Users' });
|
||||
expect(link.className).toContain('ui-accent-active');
|
||||
expect(link.className).toContain('mx-auto');
|
||||
expect(screen.getByText('Users').className).toContain('lg:hidden');
|
||||
});
|
||||
|
||||
it('renders inactive style and triggers onClick', () => {
|
||||
const onClick = vi.fn();
|
||||
renderWithRouter(
|
||||
<Routes>
|
||||
<Route path="/users" element={<div>Users page</div>} />
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<SidebarNavItem
|
||||
to="/users"
|
||||
label="Users"
|
||||
icon={UserCircleIcon}
|
||||
collapsed={false}
|
||||
onClick={onClick}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{ route: '/profile' },
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Users' });
|
||||
expect(link.className).toContain('hover:bg-zinc-500/15');
|
||||
expect(link.className).toContain('lg:w-full');
|
||||
expect(screen.getByText('Users').className).toContain('truncate');
|
||||
|
||||
fireEvent.click(link);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
199
tests/components/Table.test.tsx
Normal file
199
tests/components/Table.test.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Table, type TableHeader } from '../../src/components/Table';
|
||||
import type { SortState } from '../../src/types/sort';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const headers: TableHeader<Row>[] = [
|
||||
{ label: 'Name', id: 'name', value: (row) => row.name, headerClassName: 'head-name' },
|
||||
{ label: 'Email', id: 'email', value: (row) => row.email, cellClassName: 'cell-email' },
|
||||
{ label: 'Static', id: 'static', value: 'Always', sortable: false },
|
||||
];
|
||||
|
||||
const data: Row[] = [{ id: '1', name: 'Jane', email: 'jane@example.com' }];
|
||||
|
||||
describe('Table', () => {
|
||||
it('renders loading state', () => {
|
||||
render(<Table headers={headers} data={[]} isLoading rowKey={(row) => row.id} />);
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders empty state message when no rows are available', () => {
|
||||
render(
|
||||
<Table
|
||||
headers={headers}
|
||||
data={[]}
|
||||
emptyMessage="Nothing here"
|
||||
rowKey={(row) => row.id}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Nothing here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders row values from function and static header values', () => {
|
||||
const rowKey = vi.fn((row: Row) => row.id);
|
||||
render(<Table headers={headers} data={data} rowKey={rowKey} />);
|
||||
|
||||
expect(screen.getByText('Jane')).toBeInTheDocument();
|
||||
expect(screen.getByText('jane@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Always')).toBeInTheDocument();
|
||||
expect(screen.getByText('Name').closest('th')).toHaveClass('head-name');
|
||||
expect(screen.getByText('jane@example.com').closest('td')).toHaveClass('cell-email');
|
||||
expect(rowKey).toHaveBeenCalledWith(data[0], 0);
|
||||
});
|
||||
|
||||
it('shows sortable buttons only when sort config is complete', () => {
|
||||
const onSortChange = vi.fn();
|
||||
const sortableHeaders: TableHeader<Row>[] = [
|
||||
{ label: 'No field', id: 'a', sortable: true, value: (row) => row.name },
|
||||
{ label: 'Empty field', id: 'b', sortable: true, sortField: '', value: (row) => row.name },
|
||||
{ label: 'Name', id: 'c', sortable: true, sortField: 'name', value: (row) => row.name },
|
||||
];
|
||||
|
||||
const { rerender } = render(
|
||||
<Table headers={sortableHeaders} data={data} rowKey={(row) => row.id} onSortChange={onSortChange} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Sort by No field' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Sort by Empty field' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Sort by Name' })).toBeInTheDocument();
|
||||
|
||||
rerender(<Table headers={sortableHeaders} data={data} rowKey={(row) => row.id} />);
|
||||
expect(screen.queryByRole('button', { name: 'Sort by Name' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sort states and notifies callback on header click', () => {
|
||||
const onSortChange = vi.fn();
|
||||
const sortableHeaders: TableHeader<Row>[] = [
|
||||
{ label: 'Name', id: 'name', sortable: true, sortField: 'name', value: (row) => row.name },
|
||||
{ label: 'Email', id: 'email', sortable: true, sortField: 'email', value: (row) => row.email },
|
||||
];
|
||||
const sorting: SortState = { field: 'name', direction: 'asc' };
|
||||
|
||||
const { rerender } = render(
|
||||
<Table
|
||||
headers={sortableHeaders}
|
||||
data={data}
|
||||
rowKey={(row) => row.id}
|
||||
sorting={sorting}
|
||||
onSortChange={onSortChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const nameSort = screen.getByRole('button', { name: 'Sort by Name' });
|
||||
const emailSort = screen.getByRole('button', { name: 'Sort by Email' });
|
||||
expect(nameSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'asc');
|
||||
expect(emailSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'none');
|
||||
|
||||
fireEvent.click(nameSort);
|
||||
expect(onSortChange).toHaveBeenCalledWith('name');
|
||||
|
||||
rerender(
|
||||
<Table
|
||||
headers={sortableHeaders}
|
||||
data={data}
|
||||
rowKey={(row) => row.id}
|
||||
sorting={{ field: 'name', direction: 'desc' }}
|
||||
onSortChange={onSortChange}
|
||||
/>,
|
||||
);
|
||||
expect(nameSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'desc');
|
||||
});
|
||||
|
||||
it('supports pagination controls and page-size changes', () => {
|
||||
const onPageChange = vi.fn();
|
||||
const onPageSizeChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Table
|
||||
headers={headers}
|
||||
data={data}
|
||||
rowKey={(row) => row.id}
|
||||
pagination={{
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
total: 21,
|
||||
totalPages: 3,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous page' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next page' }));
|
||||
fireEvent.change(screen.getByLabelText('Rows'), { target: { value: '20' } });
|
||||
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(1, 1);
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(2, 3);
|
||||
expect(onPageSizeChange).toHaveBeenCalledWith(20);
|
||||
});
|
||||
|
||||
it('hides rows selector when onPageSizeChange is absent and clamps page count display', () => {
|
||||
render(
|
||||
<Table
|
||||
headers={headers}
|
||||
data={data}
|
||||
rowKey={(row) => row.id}
|
||||
pagination={{
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 1,
|
||||
totalPages: 0,
|
||||
onPageChange: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('Rows')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Page 1 of 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables prev/next at bounds or while loading', () => {
|
||||
const { rerender } = render(
|
||||
<Table
|
||||
headers={headers}
|
||||
data={data}
|
||||
rowKey={(row) => row.id}
|
||||
pagination={{
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 10,
|
||||
totalPages: 1,
|
||||
onPageChange: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
|
||||
|
||||
rerender(
|
||||
<Table
|
||||
headers={headers}
|
||||
data={data}
|
||||
rowKey={(row) => row.id}
|
||||
isLoading
|
||||
pagination={{
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
total: 100,
|
||||
totalPages: 10,
|
||||
onPageChange: vi.fn(),
|
||||
onPageSizeChange: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
|
||||
expect(screen.getByLabelText('Rows')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user