diff --git a/app/index.html b/app/index.html index a8dd167..810d0d2 100644 --- a/app/index.html +++ b/app/index.html @@ -11,6 +11,17 @@ + diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc594..992f126 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -3,6 +3,7 @@ import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { ThemeProvider } from "@/hooks/use-theme"; import { Layout } from "./components/layout/Layout"; import { Dashboard } from "./pages/Dashboard"; import { Budgets } from "./pages/Budgets"; @@ -27,6 +28,7 @@ const queryClient = new QueryClient({ }); const App = () => ( + @@ -100,6 +102,7 @@ const App = () => ( + ); export default App; diff --git a/app/src/__tests__/Navbar.test.tsx b/app/src/__tests__/Navbar.test.tsx index dd538bd..7216727 100644 --- a/app/src/__tests__/Navbar.test.tsx +++ b/app/src/__tests__/Navbar.test.tsx @@ -2,14 +2,16 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; import { Navbar } from '@/components/layout/Navbar'; +import { ThemeProvider } from '@/hooks/use-theme'; -// Mock toast jest.mock('@/components/ui/use-toast', () => ({ useToast: () => ({ toast: jest.fn() }) })); const renderNav = () => render( - - - + + + + + ); describe('Navbar auth state', () => { diff --git a/app/src/__tests__/useTheme.test.tsx b/app/src/__tests__/useTheme.test.tsx new file mode 100644 index 0000000..eb9ac49 --- /dev/null +++ b/app/src/__tests__/useTheme.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { ThemeProvider, useTheme } from '@/hooks/use-theme'; + +const STORAGE_KEY = 'finmind-ui-theme'; + +function ThemeDisplay() { + const { theme, setTheme } = useTheme(); + return ( +
+ {theme} + + + +
+ ); +} + +const renderWithProvider = (defaultTheme?: 'dark' | 'light' | 'system') => + render( + + + + ); + +describe('useTheme', () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove('dark', 'light'); + }); + + it('defaults to system when no stored value', () => { + renderWithProvider(); + expect(screen.getByTestId('theme').textContent).toBe('system'); + }); + + it('reads persisted theme from localStorage', () => { + localStorage.setItem(STORAGE_KEY, 'dark'); + renderWithProvider(); + expect(screen.getByTestId('theme').textContent).toBe('dark'); + }); + + it('applies dark class to documentElement when theme is dark', () => { + renderWithProvider('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + expect(document.documentElement.classList.contains('light')).toBe(false); + }); + + it('applies light class to documentElement when theme is light', () => { + renderWithProvider('light'); + expect(document.documentElement.classList.contains('light')).toBe(true); + expect(document.documentElement.classList.contains('dark')).toBe(false); + }); + + it('persists theme change to localStorage', () => { + renderWithProvider(); + act(() => { + fireEvent.click(screen.getByText('Set Dark')); + }); + expect(localStorage.getItem(STORAGE_KEY)).toBe('dark'); + expect(screen.getByTestId('theme').textContent).toBe('dark'); + }); + + it('switches from dark to light and updates class', () => { + renderWithProvider('dark'); + act(() => { + fireEvent.click(screen.getByText('Set Light')); + }); + expect(document.documentElement.classList.contains('light')).toBe(true); + expect(document.documentElement.classList.contains('dark')).toBe(false); + }); + + it('returns a stable setTheme function across renders', () => { + const { rerender } = renderWithProvider('light'); + const firstRender = screen.getByText('Set Dark'); + rerender( + + + + ); + expect(screen.getByText('Set Dark')).toBe(firstRender); + }); + + it('ignores invalid localStorage values and falls back to defaultTheme', () => { + localStorage.setItem(STORAGE_KEY, 'foobar'); + renderWithProvider('light'); + expect(screen.getByTestId('theme').textContent).toBe('light'); + expect(document.documentElement.classList.contains('light')).toBe(true); + }); + + it('ignores empty string in localStorage and falls back to defaultTheme', () => { + localStorage.setItem(STORAGE_KEY, ''); + renderWithProvider('dark'); + expect(screen.getByTestId('theme').textContent).toBe('dark'); + }); + + it('responds to system prefers-color-scheme change when theme is system', () => { + let changeHandler: (() => void) | null = null; + const mockMq = { + matches: false, + addEventListener: jest.fn((_event: string, handler: () => void) => { + changeHandler = handler; + }), + removeEventListener: jest.fn(), + }; + jest.spyOn(window, 'matchMedia').mockReturnValue(mockMq as unknown as MediaQueryList); + + renderWithProvider('system'); + + act(() => { + mockMq.matches = true; + if (changeHandler) changeHandler(); + }); + + expect(document.documentElement.classList.contains('dark')).toBe(true); + + (window.matchMedia as jest.Mock).mockRestore?.(); + }); + + it('throws when used outside ThemeProvider', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow('useTheme must be used within a ThemeProvider'); + consoleError.mockRestore(); + }); +}); diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b7..20c2e1e 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { Menu, X, TrendingUp, ShieldCheck } from 'lucide-react'; +import { Menu, X, TrendingUp, ShieldCheck, Sun, Moon, Monitor } from 'lucide-react'; import { getToken, getRefreshToken, clearToken, clearRefreshToken } from '@/lib/auth'; import { useToast } from '@/components/ui/use-toast'; import { logout as logoutApi } from '@/api/auth'; +import { useTheme } from '@/hooks/use-theme'; const navigation = [ { name: 'Dashboard', href: '/dashboard' }, @@ -15,6 +16,30 @@ const navigation = [ { name: 'Analytics', href: '/analytics' }, ]; +function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + const cycle = () => { + if (theme === 'light') setTheme('dark'); + else if (theme === 'dark') setTheme('system'); + else setTheme('light'); + }; + + const Icon = theme === 'dark' ? Moon : theme === 'light' ? Sun : Monitor; + const label = theme === 'dark' ? 'Dark' : theme === 'light' ? 'Light' : 'System'; + + return ( + + ); +} + export function Navbar() { const [isOpen, setIsOpen] = useState(false); const [isAuthed, setIsAuthed] = useState(!!getToken()); @@ -80,10 +105,11 @@ export function Navbar() {
-
+
Enterprise-grade security
+ {isAuthed ? ( <>