Skip to content
Open
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
15,110 changes: 3,507 additions & 11,603 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

43 changes: 38 additions & 5 deletions src/components/admin/AdminThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,44 @@
import React from 'react';
import { Moon, Sun, Monitor } from 'lucide-react';
import { useThemeContext, Theme } from '@/contexts/ThemeContext';
import { ErrorBoundary } from '@/components/errors/ErrorBoundarySystem';
import { errorReportingService } from '@/services/errorReporting';
import AdminThemeToggleFallback from './AdminThemeToggleFallback';

/**
* Admin-specific dark mode toggle for the admin panel header.
* Provides light/dark/system mode switching with visual feedback.
* Inner component that uses theme context and performs actions.
* Can throw if context is missing.
*/
export default function AdminThemeToggle() {
const { theme, setTheme, resolvedTheme } = useThemeContext();
function AdminThemeToggleControl() {
const { theme, setTheme } = useThemeContext();

const options: { value: Theme; icon: React.ReactNode; label: string }[] = [
{ value: 'light', icon: <Sun className="w-4 h-4" />, label: 'Light' },
{ value: 'dark', icon: <Moon className="w-4 h-4" />, label: 'Dark' },
{ value: 'system', icon: <Monitor className="w-4 h-4" />, label: 'System' },
];

const handleSelectTheme = (value: Theme) => {
try {
setTheme(value);
} catch (err) {
if (typeof errorReportingService?.reportError === 'function') {
errorReportingService.reportError(err instanceof Error ? err : new Error(String(err)), {
component: 'AdminThemeToggle',
action: 'handleSelectTheme',
selectedTheme: value,
});
}
}
};

return (
<div className="flex items-center gap-1 p-1 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
{options.map((opt) => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
type="button"
onClick={() => handleSelectTheme(opt.value)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 ${
theme === opt.value
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
Expand All @@ -38,3 +56,18 @@ export default function AdminThemeToggle() {
</div>
);
}

/**
* Admin-specific dark mode toggle wrapped with ErrorBoundary.
*/
export default function AdminThemeToggle() {
return (
<ErrorBoundary
fallback={<AdminThemeToggleFallback />}
isolationId="admin-theme-toggle"
isolationLevel="component"
>
<AdminThemeToggleControl />
</ErrorBoundary>
);
}
37 changes: 37 additions & 0 deletions src/components/admin/AdminThemeToggleFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client';

import React from 'react';
import { Moon, Sun, Monitor } from 'lucide-react';

/**
* Fallback component for AdminThemeToggle in case of theme context/state failure.
* Renders disabled but visually aligned buttons to prevent layout shifting.
*/
export default function AdminThemeToggleFallback() {
const options = [
{ key: 'light', icon: <Sun className="w-4 h-4" />, label: 'Light' },
{ key: 'dark', icon: <Moon className="w-4 h-4" />, label: 'Dark' },
{ key: 'system', icon: <Monitor className="w-4 h-4" />, label: 'System' },
];

return (
<div
className="flex items-center gap-1 p-1 rounded-lg bg-gray-100 dark:bg-gray-850 border border-gray-200 dark:border-gray-800 opacity-60 cursor-not-allowed"
aria-label="Admin theme toggle temporarily unavailable"
title="Theme toggle unavailable"
>
{options.map((opt) => (
<button
key={opt.key}
type="button"
disabled
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-gray-400 dark:text-gray-500 cursor-not-allowed pointer-events-none"
aria-label={`${opt.label} mode toggle unavailable`}
>
{opt.icon}
<span className="hidden sm:inline">{opt.label}</span>
</button>
))}
</div>
);
}
114 changes: 114 additions & 0 deletions src/components/admin/__tests__/AdminThemeToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ThemeProvider } from '@/lib/theme-provider';
import { errorReportingService } from '@/services/errorReporting';
import AdminThemeToggle from '../AdminThemeToggle';

describe('AdminThemeToggle', () => {
beforeEach(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
document.documentElement.classList.remove('light', 'dark');
window.localStorage.clear();
errorReportingService.clearBreadcrumbs();
});

it('switches between theme preferences (light, dark, system)', async () => {
render(
<ThemeProvider defaultTheme="light">
<AdminThemeToggle />
</ThemeProvider>,
);

// Initial check (should have active status or class depending on selection)
const darkBtn = await screen.findByRole('button', { name: /switch to dark mode/i });
fireEvent.click(darkBtn);

await waitFor(() => {
expect(document.documentElement).toHaveClass('dark');
});
});

it('renders an accessible visual fallback when context is missing', async () => {
const preventExpectedBoundaryError = (event: ErrorEvent) => event.preventDefault();
window.addEventListener('error', preventExpectedBoundaryError);

render(<AdminThemeToggle />);

const fallbackContainer = await screen.findByLabelText(
'Admin theme toggle temporarily unavailable',
);
expect(fallbackContainer).toBeInTheDocument();

const fallbackButtons = screen.getAllByRole('button', { name: /mode toggle unavailable/i });
expect(fallbackButtons).toHaveLength(3);
fallbackButtons.forEach((btn) => {
expect(btn).toBeDisabled();
});

window.removeEventListener('error', preventExpectedBoundaryError);

expect(errorReportingService.getBreadcrumbs()).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: 'errorBoundary',
details: expect.objectContaining({
isolationId: 'admin-theme-toggle',
isolationLevel: 'component',
}),
}),
]),
);
});

it('safely catches and logs errors inside event handler without crashing', async () => {
const spyReport = vi.spyOn(errorReportingService, 'reportError').mockResolvedValue({} as any);

const ThrowingProvider = ({ children }: { children: React.ReactNode }) => {
const mockValue = {
theme: 'light' as const,
resolvedTheme: 'light' as const,
setTheme: () => {
throw new Error('Theme change failed');
},
};
const ThemeContext = (require('@/contexts/ThemeContext') as any).ThemeContext;
if (!ThemeContext) {
throw new Error('ThemeContext not exportable');
}
return <ThemeContext.Provider value={mockValue}>{children}</ThemeContext.Provider>;
};

render(
<ThrowingProvider>
<AdminThemeToggle />
</ThrowingProvider>,
);

const darkBtn = await screen.findByRole('button', { name: /switch to dark mode/i });

expect(() => fireEvent.click(darkBtn)).not.toThrow();

expect(spyReport).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
component: 'AdminThemeToggle',
action: 'handleSelectTheme',
selectedTheme: 'dark',
}),
);

spyReport.mockRestore();
});
});
42 changes: 42 additions & 0 deletions src/components/ui/__tests__/theme-toggle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,46 @@ describe('ThemeToggle', () => {
]),
);
});

it('safely catches and logs errors inside event handler without crashing', async () => {
const spyReport = vi.spyOn(errorReportingService, 'reportError').mockResolvedValue({} as any);

// Render under custom provider where setTheme throws
const ThrowingProvider = ({ children }: { children: React.ReactNode }) => {
const mockValue = {
theme: 'light' as const,
resolvedTheme: 'light' as const,
setTheme: () => {
throw new Error('Mutation failed');
},
};
const ThemeContext = (require('@/contexts/ThemeContext') as any).ThemeContext;
if (!ThemeContext) {
// Fallback if imported context format differs
throw new Error('ThemeContext not exportable');
}
return <ThemeContext.Provider value={mockValue}>{children}</ThemeContext.Provider>;
};

render(
<ThrowingProvider>
<ThemeToggle />
</ThrowingProvider>,
);

const toggle = await screen.findByRole('button', { name: /switch to dark mode/i });

// Clicking should not crash the rendering tree
expect(() => fireEvent.click(toggle)).not.toThrow();

expect(spyReport).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
component: 'ThemeToggle',
action: 'handleToggle',
}),
);

spyReport.mockRestore();
});
});
15 changes: 12 additions & 3 deletions src/components/ui/theme-toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,18 @@ function ThemeToggleControl() {
typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false;

const handleToggle = () => {
const next = prefersDark ? 'light' : 'dark';
setTheme(next);
patchSettings({ theme: next });
try {
const next = prefersDark ? 'light' : 'dark';
setTheme(next);
patchSettings({ theme: next });
} catch (err) {
if (typeof errorReportingService?.reportError === 'function') {
errorReportingService.reportError(err instanceof Error ? err : new Error(String(err)), {
component: 'ThemeToggle',
action: 'handleToggle',
});
}
}
};

return (
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.tsbuildinfo

Large diffs are not rendered by default.