Skip to content
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
93 changes: 93 additions & 0 deletions apps/web/src/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router';
import { RootErrorBoundary } from './ErrorBoundary.tsx';

vi.mock('@/utils/logger.ts', () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
}),
}));

vi.mock('../contexts/I18nContext.tsx', () => ({
useTranslation: () => ({
t: (k: string) => k,
locale: 'en',
setLocale: vi.fn(),
availableLocales: ['en', 'tr'],
}),
}));

beforeEach(() => {
vi.clearAllMocks();
});

function renderWithLoader(loader: () => never) {
const router = createMemoryRouter(
[
{
path: '/',
element: <div data-testid='page-content'>Page</div>,
loader,
errorElement: <RootErrorBoundary />,
},
],
{ initialEntries: ['/'] },
);
return render(<RouterProvider router={router} />);
}

describe('RootErrorBoundary', () => {
it('renders NotFoundPage for 404 route errors', async () => {
renderWithLoader(() => {
throw new Response(null, { status: 404 });
});

expect(await screen.findByText('404')).toBeInTheDocument();
expect(screen.getByText('error.404')).toBeInTheDocument();
});

it('renders ServerErrorPage for 500 route errors', async () => {
renderWithLoader(() => {
throw new Response(null, { status: 500 });
});

expect(await screen.findByText('500')).toBeInTheDocument();
expect(screen.getByText('error.500')).toBeInTheDocument();
});

it('renders ServerErrorPage for 503 route errors', async () => {
renderWithLoader(() => {
throw new Response(null, { status: 503 });
});

expect(await screen.findByText('500')).toBeInTheDocument();
});

it('renders generic fallback with t() strings for non-route errors', async () => {
const router = createMemoryRouter(
[
{
path: '/',
element: <ThrowError />,
errorElement: <RootErrorBoundary />,
},
],
{ initialEntries: ['/'] },
);
render(<RouterProvider router={router} />);

expect(await screen.findByText('error.boundary.oops')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'common.goHome' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'error.boundary.reload' })).toBeInTheDocument();
});
});

function ThrowError() {
throw new Error('boom');
}
43 changes: 11 additions & 32 deletions apps/web/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { isRouteErrorResponse, Link, useRouteError } from 'react-router';
import { NotFoundPage, ServerErrorPage } from '../pages/ErrorPage.tsx';
import { useTranslation } from '../contexts/I18nContext.tsx';
import { createLogger } from '@/utils/logger.ts';

const log = createLogger('ErrorBoundary');

/**
* Router-level error boundary: logs the caught error and renders a
* full-page fallback (status-aware for route error responses, stack
* trace in dev) with home/reload actions.
* full-page fallback — delegating 404 to `NotFoundPage` and 5xx to
* `ServerErrorPage` from the canonical error-page module, with a
* generic fallback (Go Home + Reload Page) for non-route errors.
*/
export function RootErrorBoundary() {
const error = useRouteError();
const { t } = useTranslation();

log.error(
{ err: error, componentStack: error instanceof Error ? error.stack : undefined },
Expand All @@ -22,33 +26,8 @@ export function RootErrorBoundary() {
}

if (isRouteErrorResponse(error)) {
return (
<div
className='flex min-h-screen flex-col items-center justify-center px-6 text-center'
style={{ backgroundColor: 'var(--bg-primary)', color: 'var(--text-primary)' }}
>
<h1 className='text-6xl font-bold' style={{ color: 'var(--accent-primary)' }}>
{error.status}
</h1>
<p className='mt-4 text-lg' style={{ color: 'var(--text-secondary)' }}>
{error.status === 404
? "Looks like this cup is empty. The page you're looking for doesn't exist."
: error.statusText || 'Something went wrong.'}
</p>
<div className='mt-6 flex gap-4'>
<Link to='/' className='btn-primary'>
Go Home
</Link>
<button
type='button'
className='btn-primary'
onClick={handleReset}
>
Reload Page
</button>
</div>
</div>
);
if (error.status === 404) return <NotFoundPage />;
if (error.status >= 500) return <ServerErrorPage />;
}

const message = error instanceof Error ? error.message : 'An unexpected error occurred.';
Expand All @@ -59,7 +38,7 @@ export function RootErrorBoundary() {
style={{ backgroundColor: 'var(--bg-primary)', color: 'var(--text-primary)' }}
>
<h1 className='text-6xl font-bold' style={{ color: 'var(--accent-primary)' }}>
Oops
{t('error.boundary.oops')}
</h1>
<p className='mt-4 text-lg' style={{ color: 'var(--text-secondary)' }}>
{message}
Expand All @@ -74,14 +53,14 @@ export function RootErrorBoundary() {
)}
<div className='mt-6 flex gap-4'>
<Link to='/' className='btn-primary'>
Go Home
{t('common.goHome')}
</Link>
<button
type='button'
className='btn-primary'
onClick={handleReset}
>
Reload Page
{t('error.boundary.reload')}
</button>
</div>
</div>
Expand Down
92 changes: 92 additions & 0 deletions apps/web/src/components/admin/BanDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BanDialog } from './BanDialog.tsx';

vi.mock('@/utils/logger.ts', () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
}),
}));

vi.mock('../../contexts/I18nContext.tsx', () => ({
useTranslation: () => ({
t: (k: string) => k,
locale: 'en',
setLocale: vi.fn(),
availableLocales: ['en', 'tr'],
}),
}));

beforeEach(() => {
vi.clearAllMocks();
});

const defaultProps = {
user: { id: 'u1', username: 'alice', displayName: 'Alice A' },
open: true,
onClose: vi.fn(),
onConfirm: vi.fn(),
processing: false,
};

describe('BanDialog', () => {
it('renders user name (displayName) in title when open', () => {
render(<BanDialog {...defaultProps} />);
expect(screen.getByText(/admin\.users\.banDialogTitle: Alice A/)).toBeInTheDocument();
const textarea = screen.getByPlaceholderText('admin.users.banReasonPlaceholder');
expect(textarea).toHaveValue('');
});

it('renders username in title when displayName is null', () => {
render(
<BanDialog
{...defaultProps}
user={{ id: 'u1', username: 'bob', displayName: null }}
/>,
);
expect(screen.getByText(/admin\.users\.banDialogTitle: bob/)).toBeInTheDocument();
});

it('confirm button is disabled when reason is empty', () => {
render(<BanDialog {...defaultProps} />);
const confirmButton = screen.getByRole('button', { name: 'admin.users.confirmBan' });
expect(confirmButton).toBeDisabled();
});

it('typing a reason and clicking confirm calls onConfirm with the reason', async () => {
const user = userEvent.setup();
render(<BanDialog {...defaultProps} />);
const textarea = screen.getByPlaceholderText('admin.users.banReasonPlaceholder');
await user.type(textarea, 'Spam');
const confirmButton = screen.getByRole('button', { name: 'admin.users.confirmBan' });
await user.click(confirmButton);
expect(defaultProps.onConfirm).toHaveBeenCalledWith('Spam');
});

it('clicking cancel calls onClose', async () => {
const user = userEvent.setup();
render(<BanDialog {...defaultProps} />);
const cancelButton = screen.getByRole('button', { name: 'common.cancel' });
await user.click(cancelButton);
expect(defaultProps.onClose).toHaveBeenCalled();
});

it('processing true disables buttons and shows banning label', () => {
render(<BanDialog {...defaultProps} processing />);
const confirmButton = screen.getByRole('button', { name: 'admin.users.banning' });
expect(confirmButton).toBeDisabled();
const cancelButton = screen.getByRole('button', { name: 'common.cancel' });
expect(cancelButton).toBeDisabled();
});

it('renders nothing when open is false', () => {
render(<BanDialog {...defaultProps} open={false} />);
expect(screen.queryByText(/admin\.users\.banDialogTitle/)).not.toBeInTheDocument();
});
});
83 changes: 83 additions & 0 deletions apps/web/src/components/admin/BanDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import { useTranslation } from '../../contexts/I18nContext.tsx';
import { createLogger } from '@/utils/logger.ts';

const log = createLogger('BanDialog');

/**
* Props for the {@link BanDialog} component.
*/
interface BanDialogProps {
/** The user targeted by the ban action. */
user: { id: string; username: string; displayName: string | null };
/** Whether the dialog is currently visible. */
open: boolean;
/** Called when the user requests to close the dialog without confirming. */
onClose: () => void;
/** Called with the entered reason when the user confirms the ban. */
onConfirm: (reason: string) => void;
/** When true, both buttons are disabled and the confirm button shows a "banning" label. */
processing: boolean;
}

/**
* Controlled modal dialog for confirming a ban action against a user. Owns the
* reason textarea state internally and reports the entered reason via
* `onConfirm`. The parent controls visibility (`open`) and the processing state.
*/
export function BanDialog({ user, open, onClose, onConfirm, processing }: BanDialogProps) {
const { t } = useTranslation();
const [reason, setReason] = useState('');

useEffect(() => {
log.debug({ userId: user.id, open }, 'BanDialog render');
}, [open]);

if (!open) return null;

return (
<div
className='fixed inset-0 flex items-center justify-center z-50'
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
>
<div className='card max-w-md w-full mx-4'>
<h3 className='font-semibold mb-4' style={{ color: 'var(--text-primary)' }}>
{`${t('admin.users.banDialogTitle')}: ${user.displayName || user.username}`}
</h3>
<label
className='block text-sm font-medium mb-1'
style={{ color: 'var(--text-secondary)' }}
>
{t('admin.users.banReason')}
<textarea
className='input-field'
rows={3}
placeholder={t('admin.users.banReasonPlaceholder')}
autoFocus
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</label>
<div className='flex gap-2 justify-end'>
<button
type='button'
onClick={onClose}
className='btn-secondary'
disabled={processing}
>
{t('common.cancel')}
</button>
<button
type='button'
onClick={() => onConfirm(reason)}
disabled={processing || !reason.trim()}
className='btn-primary'
style={{ backgroundColor: 'var(--error)' }}
>
{processing ? t('admin.users.banning') : t('admin.users.confirmBan')}
</button>
</div>
</div>
</div>
);
}
24 changes: 24 additions & 0 deletions apps/web/src/components/form/Field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ReactNode } from 'react';

interface FieldProps {
label: string;
required?: boolean;
children: ReactNode;
}

/**
* Form field layout primitive. Renders a `<label>` (with optional required
* indicator) followed by the field children. The `label` prop is already
* translated by the caller (this component does not call `t()`).
*/
export function Field({ label, required, children }: FieldProps) {
return (
<label className='block mb-1' style={{ color: 'var(--text-secondary)' }}>
<span className='label-text text-sm font-medium'>
{label}
{required && ' *'}
</span>
{children}
</label>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading