Implemented comprehensive loading and error handling components for the WellNuo web application with full test coverage. Components added: - LoadingSpinner: Configurable spinner with sizes, colors, and variants * LoadingOverlay: Full-screen loading with backdrop options * InlineLoader: Small inline spinner for buttons * PageLoader: Full-page centered loading state - ErrorMessage: Inline error messages with severity levels * FullScreenError: Full-page error states with retry/back actions * FieldError: Form field validation errors * ErrorBoundaryFallback: Error boundary fallback component * EmptyState: Empty data state component - Skeleton: Animated loading skeletons * SkeletonAvatar: Circular avatar skeleton * SkeletonText: Multi-line text skeleton * SkeletonCard: Card-style skeleton * SkeletonList: List of skeleton cards * SkeletonTable: Table skeleton with rows/columns * SkeletonDashboard: Dashboard-style skeleton layout * SkeletonForm: Form skeleton with fields Technical details: - Tailwind CSS styling with cn() utility function - Full accessibility support (ARIA labels, roles) - Comprehensive test coverage (57 tests, all passing) - TypeScript strict mode compliance - Added clsx and tailwind-merge dependencies Files: - web/components/ui/LoadingSpinner.tsx - web/components/ui/ErrorMessage.tsx - web/components/ui/Skeleton.tsx - web/components/ui/index.ts - web/lib/utils.ts - web/components/ui/__tests__/*.test.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
207 lines
6.1 KiB
TypeScript
207 lines
6.1 KiB
TypeScript
import { render, screen, fireEvent } from '@testing-library/react';
|
|
import {
|
|
ErrorMessage,
|
|
FullScreenError,
|
|
FieldError,
|
|
EmptyState,
|
|
} from '../ErrorMessage';
|
|
|
|
describe('ErrorMessage Component', () => {
|
|
it('renders error message', () => {
|
|
render(<ErrorMessage message="Something went wrong" />);
|
|
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders with different severity levels', () => {
|
|
const { rerender, container } = render(
|
|
<ErrorMessage message="Error" severity="error" />
|
|
);
|
|
let alert = container.querySelector('[role="alert"]');
|
|
expect(alert).toHaveClass('bg-red-50', 'border-red-200');
|
|
|
|
rerender(<ErrorMessage message="Warning" severity="warning" />);
|
|
alert = container.querySelector('[role="alert"]');
|
|
expect(alert).toHaveClass('bg-yellow-50', 'border-yellow-200');
|
|
|
|
rerender(<ErrorMessage message="Info" severity="info" />);
|
|
alert = container.querySelector('[role="alert"]');
|
|
expect(alert).toHaveClass('bg-blue-50', 'border-blue-200');
|
|
});
|
|
|
|
it('calls onRetry when retry button is clicked', () => {
|
|
const handleRetry = jest.fn();
|
|
render(<ErrorMessage message="Failed to load" onRetry={handleRetry} />);
|
|
|
|
const retryButton = screen.getByRole('button', { name: /retry/i });
|
|
fireEvent.click(retryButton);
|
|
|
|
expect(handleRetry).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('calls onDismiss when dismiss button is clicked', () => {
|
|
const handleDismiss = jest.fn();
|
|
render(<ErrorMessage message="Error" onDismiss={handleDismiss} />);
|
|
|
|
const dismissButton = screen.getByLabelText('Dismiss');
|
|
fireEvent.click(dismissButton);
|
|
|
|
expect(handleDismiss).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('renders without action buttons', () => {
|
|
render(<ErrorMessage message="Error" />);
|
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('has proper ARIA attributes', () => {
|
|
render(<ErrorMessage message="Error occurred" />);
|
|
const alert = screen.getByRole('alert');
|
|
expect(alert).toHaveAttribute('aria-live', 'polite');
|
|
});
|
|
});
|
|
|
|
describe('FullScreenError Component', () => {
|
|
it('renders full screen error with title and message', () => {
|
|
render(
|
|
<FullScreenError
|
|
title="Page Not Found"
|
|
message="The page you are looking for does not exist."
|
|
/>
|
|
);
|
|
expect(screen.getByText('Page Not Found')).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText('The page you are looking for does not exist.')
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('uses default title when not provided', () => {
|
|
render(<FullScreenError message="An error occurred" />);
|
|
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onRetry when retry button is clicked', () => {
|
|
const handleRetry = jest.fn();
|
|
render(
|
|
<FullScreenError
|
|
message="Failed to load"
|
|
onRetry={handleRetry}
|
|
/>
|
|
);
|
|
|
|
const retryButton = screen.getByRole('button', { name: /try again/i });
|
|
fireEvent.click(retryButton);
|
|
|
|
expect(handleRetry).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('calls onBack when back button is clicked', () => {
|
|
const handleBack = jest.fn();
|
|
render(
|
|
<FullScreenError
|
|
message="Error"
|
|
onBack={handleBack}
|
|
/>
|
|
);
|
|
|
|
const backButton = screen.getByRole('button', { name: /go back/i });
|
|
fireEvent.click(backButton);
|
|
|
|
expect(handleBack).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('renders with custom button labels', () => {
|
|
render(
|
|
<FullScreenError
|
|
message="Error"
|
|
onRetry={() => {}}
|
|
onBack={() => {}}
|
|
retryLabel="Reload"
|
|
backLabel="Return"
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByRole('button', { name: /reload/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /return/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders different icon types', () => {
|
|
const { container } = render(
|
|
<FullScreenError message="Error" icon="offline" />
|
|
);
|
|
// Check that SVG is rendered
|
|
expect(container.querySelector('svg')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FieldError Component', () => {
|
|
it('renders field error message', () => {
|
|
render(<FieldError message="Email is required" />);
|
|
expect(screen.getByText('Email is required')).toBeInTheDocument();
|
|
});
|
|
|
|
it('has proper ARIA role', () => {
|
|
render(<FieldError message="Invalid input" />);
|
|
const alert = screen.getByRole('alert');
|
|
expect(alert).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders with icon', () => {
|
|
const { container } = render(<FieldError message="Error" />);
|
|
const svg = container.querySelector('svg');
|
|
expect(svg).toBeInTheDocument();
|
|
});
|
|
|
|
it('applies custom className', () => {
|
|
render(<FieldError message="Error" className="mb-4" />);
|
|
const alert = screen.getByRole('alert');
|
|
expect(alert).toHaveClass('mb-4');
|
|
});
|
|
});
|
|
|
|
describe('EmptyState Component', () => {
|
|
it('renders empty state with title', () => {
|
|
render(<EmptyState title="No items found" />);
|
|
expect(screen.getByText('No items found')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders with message', () => {
|
|
render(
|
|
<EmptyState
|
|
title="No data"
|
|
message="Get started by adding your first item"
|
|
/>
|
|
);
|
|
expect(
|
|
screen.getByText('Get started by adding your first item')
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders action button when provided', () => {
|
|
const handleAction = jest.fn();
|
|
render(
|
|
<EmptyState
|
|
title="Empty"
|
|
actionLabel="Add Item"
|
|
onAction={handleAction}
|
|
/>
|
|
);
|
|
|
|
const button = screen.getByRole('button', { name: /add item/i });
|
|
expect(button).toBeInTheDocument();
|
|
|
|
fireEvent.click(button);
|
|
expect(handleAction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('renders custom icon', () => {
|
|
const icon = <div data-testid="custom-icon">Icon</div>;
|
|
render(<EmptyState title="Empty" icon={icon} />);
|
|
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not render action button when not provided', () => {
|
|
render(<EmptyState title="Empty" />);
|
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
});
|
|
});
|