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
232 changes: 205 additions & 27 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Main Application Component
* Enterprise-grade task management application
*/

import { useState, Suspense, lazy } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate, useParams } from 'react-router-dom';
import type { UserRole } from './core/types';
import { ROUTES, STORAGE_KEYS } from './core/constants';
import { getFromStorage, setInStorage } from './core/utils/storage';
import type { AppView } from './routing/appViewMapping';
import { getPathForView, getViewFromPath } from './routing/appViewMapping';
import { TaskBoard, TaskProvider } from './features/tasks';
import { AuthProvider, useAuth } from './features/auth';
import { ProjectProvider } from './features/projects';
Expand All @@ -14,17 +15,47 @@ import { Avatar } from './shared/components/Avatar';
import { CountBadge } from './shared/components/Badge';
import { Dropdown, DropdownItem, DropdownDivider, DropdownLabel } from './shared/components/Dropdown';
import { Button } from './shared/components/Button';
import { LoginPage } from './features/auth/components/LoginPage';
import { RegisterPage } from './features/auth/components/RegisterPage';
import { ForgotPasswordPage } from './features/auth/components/ForgotPasswordPage';

const ProjectDashboard = lazy(() => import('./features/projects/components/ProjectDashboard').then((m) => ({ default: m.ProjectDashboard })));
const TeamList = lazy(() => import('./features/teams/components/TeamList').then((m) => ({ default: m.TeamList })));
const AnalyticsDashboard = lazy(() => import('./features/analytics/components/AnalyticsDashboard').then((m) => ({ default: m.AnalyticsDashboard })));

interface AuthGateProps {
children: JSX.Element;
requiredRole?: UserRole | UserRole[];
requiredPermission?: string;
}

// Lazy load heavy components
const ProjectDashboard = lazy(() => import('./features/projects/components/ProjectDashboard').then(m => ({ default: m.ProjectDashboard })));
const TeamList = lazy(() => import('./features/teams/components/TeamList').then(m => ({ default: m.TeamList })));
const AnalyticsDashboard = lazy(() => import('./features/analytics/components/AnalyticsDashboard').then(m => ({ default: m.AnalyticsDashboard })));
function AuthGate({ children, requiredRole, requiredPermission }: AuthGateProps) {
const { isAuthenticated, isLoading, hasRole, hasPermission } = useAuth();
const location = useLocation();

// ============================================
// NAVIGATION
// ============================================
if (isLoading) {
return (
<div className="auth-loading">
<Spinner size="lg" />
<span>Checking authentication...</span>
</div>
);
}

if (!isAuthenticated) {
return <Navigate to={ROUTES.LOGIN} replace state={{ from: location }} />;
}

if (requiredRole && !hasRole(requiredRole)) {
return <Navigate to={ROUTES.FORBIDDEN} replace />;
}

type AppView = 'tasks' | 'projects' | 'teams' | 'analytics' | 'settings';
if (requiredPermission && !hasPermission(requiredPermission)) {
return <Navigate to={ROUTES.FORBIDDEN} replace />;
}

return children;
}

interface NavItemProps {
icon: React.ReactNode;
Expand Down Expand Up @@ -306,18 +337,21 @@ interface MainContentProps {
}

function MainContent({ currentView }: MainContentProps) {
const params = useParams<{ projectId?: string }>();
const projectIdFromParams = params.projectId || 'proj_001';

const renderContent = () => {
switch (currentView) {
case 'tasks':
return (
<TaskProvider>
<TaskBoard />
</TaskProvider>
return (
<TaskProvider>
<TaskBoard />
</TaskProvider>
);
case 'projects':
return (
<Suspense fallback={<LoadingFallback />}>
<ProjectDashboard projectId="proj_001" />
<ProjectDashboard projectId={projectIdFromParams} />
</Suspense>
);
case 'teams':
Expand Down Expand Up @@ -451,17 +485,37 @@ function SettingsPage() {
// ============================================

function AppLayout() {
const [currentView, setCurrentView] = useState<AppView>('tasks');
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const stored = getFromStorage<boolean>(STORAGE_KEYS.SIDEBAR_COLLAPSED);
return stored ?? false;
});
const [searchOpen, setSearchOpen] = useState(false);
const currentView = getViewFromPath(location.pathname);

const handleViewChange = (view: AppView) => {
const targetPath = getPathForView(view);
if (location.pathname !== targetPath) {
navigate(targetPath);
}
};

const handleToggleCollapse = () => {
setSidebarCollapsed((prev) => {
const next = !prev;
setInStorage(STORAGE_KEYS.SIDEBAR_COLLAPSED, next);
return next;
});
};

return (
<div className={`app-layout ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
<Sidebar
currentView={currentView}
onViewChange={setCurrentView}
onViewChange={handleViewChange}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
onToggleCollapse={handleToggleCollapse}
/>
<div className="app-main">
<Header onOpenSearch={() => setSearchOpen(true)} />
Expand All @@ -471,24 +525,148 @@ function AppLayout() {
isOpen={searchOpen}
onClose={() => setSearchOpen(false)}
onNavigate={(url) => {
console.log('Navigate to:', url);
navigate(url);
setSearchOpen(false);
}}
/>
</div>
);
}

// ============================================
// APP WITH PROVIDERS
// ============================================
function ForbiddenPage() {
const navigate = useNavigate();

return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">Access denied</h1>
<p className="page-subtitle">You do not have permission to view this page.</p>
</div>
<Button variant="secondary" onClick={() => navigate(ROUTES.TASKS)}>
Go back to tasks
</Button>
</div>
);
}

function NotFoundPage() {
const navigate = useNavigate();

return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">Page not found</h1>
<p className="page-subtitle">The page you are looking for does not exist.</p>
</div>
<Button variant="secondary" onClick={() => navigate(ROUTES.TASKS)}>
Go back home
</Button>
</div>
);
}

function AppRoutes() {
const { isAuthenticated } = useAuth();

return (
<Routes>
<Route
path={ROUTES.HOME}
element={<Navigate to={isAuthenticated ? ROUTES.TASKS : ROUTES.LOGIN} replace />}
/>
<Route
path={ROUTES.LOGIN}
element={isAuthenticated ? <Navigate to={ROUTES.TASKS} replace /> : <LoginPage />}
/>
<Route
path={ROUTES.REGISTER}
element={isAuthenticated ? <Navigate to={ROUTES.TASKS} replace /> : <RegisterPage />}
/>
<Route
path={ROUTES.FORGOT_PASSWORD}
element={isAuthenticated ? <Navigate to={ROUTES.TASKS} replace /> : <ForgotPasswordPage />}
/>
<Route
path={ROUTES.DASHBOARD}
element={<Navigate to={ROUTES.TASKS} replace />}
/>
<Route
path={ROUTES.MY_TASKS}
element={(
<AuthGate>
<AppLayout />
</AuthGate>
)}
/>
<Route
path={ROUTES.TASKS}
element={(
<AuthGate>
<AppLayout />
</AuthGate>
)}
/>
<Route
path={ROUTES.TASK_DETAIL}
element={(
<AuthGate>
<AppLayout />
</AuthGate>
)}
/>
<Route
path={ROUTES.PROJECTS}
element={(
<AuthGate>
<AppLayout />
</AuthGate>
)}
/>
<Route
path={ROUTES.PROJECT_DETAIL}
element={(
<AuthGate>
<AppLayout />
</AuthGate>
)}
/>
<Route
path={ROUTES.TEAMS}
element={(
<AuthGate>
<AppLayout />
</AuthGate>
)}
/>
<Route
path={ROUTES.ANALYTICS}
element={(
<AuthGate>
<AppLayout />
</AuthGate>
)}
/>
<Route
path={ROUTES.SETTINGS}
element={(
<AuthGate>
<AppLayout />
</AuthGate>
)}
/>
<Route path={ROUTES.FORBIDDEN} element={<ForbiddenPage />} />
<Route path={ROUTES.NOT_FOUND} element={<NotFoundPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}

function App() {
return (
<ToastProvider>
<AuthProvider>
<ProjectProvider>
<AppLayout />
<AppRoutes />
</ProjectProvider>
</AuthProvider>
</ToastProvider>
Expand Down
28 changes: 28 additions & 0 deletions src/AppRouter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import App from './App';

function renderWithRouter(initialEntries: string[]) {
return render(
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route path="/*" element={<App />} />
</Routes>
</MemoryRouter>,
);
}

describe('App routing', () => {
it('renders login page on /login', () => {
renderWithRouter(['/login']);
expect(screen.getByText(/welcome back/i)).toBeInTheDocument();
});

it('renders not found page for unknown routes', () => {
renderWithRouter(['/unknown-route']);
expect(screen.getByText(/page not found/i)).toBeInTheDocument();
});
});


18 changes: 18 additions & 0 deletions src/features/auth/components/ForgotPasswordPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useNavigate } from 'react-router-dom';
import { ROUTES } from '../../../core/constants';
import { ForgotPasswordForm } from './ForgotPasswordForm';

export function ForgotPasswordPage() {
const navigate = useNavigate();

return (
<div className="auth-page">
<ForgotPasswordForm
onSuccess={() => undefined}
onBack={() => navigate(ROUTES.LOGIN)}
/>
</div>
);
}


32 changes: 32 additions & 0 deletions src/features/auth/components/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ROUTES } from '../../../core/constants';
import { LoginForm } from './LoginForm';

interface LocationState {
from?: {
pathname: string;
};
}

export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();

const redirectPath = useMemo(() => {
const state = location.state as LocationState | null;
return state?.from?.pathname || ROUTES.TASKS;
}, [location.state]);

return (
<div className="auth-page">
<LoginForm
onSuccess={() => navigate(redirectPath, { replace: true })}
onForgotPassword={() => navigate(ROUTES.FORGOT_PASSWORD)}
onRegister={() => navigate(ROUTES.REGISTER)}
/>
</div>
);
}


18 changes: 18 additions & 0 deletions src/features/auth/components/RegisterPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useNavigate } from 'react-router-dom';
import { ROUTES } from '../../../core/constants';
import { RegisterForm } from './RegisterForm';

export function RegisterPage() {
const navigate = useNavigate();

return (
<div className="auth-page">
<RegisterForm
onSuccess={() => navigate(ROUTES.TASKS)}
onLogin={() => navigate(ROUTES.LOGIN)}
/>
</div>
);
}


Loading