diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 3181a25e..1116b739 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -1,11 +1,10 @@ // Core dependencies -import React, { Suspense, useEffect } from 'react'; +import React, { Suspense, useEffect, memo, useMemo, useCallback } from 'react'; import { RouterProvider } from 'react-router-dom'; import i18next from 'i18next'; // Components import ThemeWrapper from './features/theme/ThemeWrapper'; -import PreferenceSelector from './components/PreferenceSelector'; // Routes import router from './app/routes'; @@ -20,36 +19,72 @@ import { Language } from './features/i18n/localesSlice'; import logger from './utils/errorLogger'; import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback'; -const App: React.FC<{ children: React.ReactNode }> = ({ children }) => { +/** + * Main App Component - Performance Optimized + * + * Performance optimizations applied: + * 1. React.memo() - Prevents unnecessary re-renders + * 2. useMemo() - Memoizes expensive computations + * 3. useCallback() - Memoizes event handlers + * 4. Lazy loading - All route components loaded on demand + * 5. Suspense boundaries - Better loading states + * 6. Optimized guard components with memoization + */ +const App: React.FC = memo(() => { const themeMode = useAppSelector(state => state.themeReducer.mode); const language = useAppSelector(state => state.localesReducer.lng); - initMixpanel(import.meta.env.VITE_MIXPANEL_TOKEN as string); + // Memoize mixpanel initialization to prevent re-initialization + const mixpanelToken = useMemo(() => import.meta.env.VITE_MIXPANEL_TOKEN as string, []); + + useEffect(() => { + initMixpanel(mixpanelToken); + }, [mixpanelToken]); + + // Memoize language change handler + const handleLanguageChange = useCallback((lng: string) => { + i18next.changeLanguage(lng, err => { + if (err) return logger.error('Error changing language', err); + }); + }, []); useEffect(() => { document.documentElement.setAttribute('data-theme', themeMode); }, [themeMode]); useEffect(() => { - i18next.changeLanguage(language || Language.EN, err => { - if (err) return logger.error('Error changing language', err); - }); - }, [language]); + handleLanguageChange(language || Language.EN); + }, [language, handleLanguageChange]); - // Initialize CSRF token on app startup + // Initialize CSRF token on app startup - memoize to prevent re-initialization useEffect(() => { + let isMounted = true; + initializeCsrfToken().catch(error => { - logger.error('Failed to initialize CSRF token:', error); + if (isMounted) { + logger.error('Failed to initialize CSRF token:', error); + } }); + + return () => { + isMounted = false; + }; }, []); return ( }> - + ); -}; +}); + +App.displayName = 'App'; export default App; diff --git a/worklenz-frontend/src/app/performance-monitor.ts b/worklenz-frontend/src/app/performance-monitor.ts new file mode 100644 index 00000000..b4146d3e --- /dev/null +++ b/worklenz-frontend/src/app/performance-monitor.ts @@ -0,0 +1,122 @@ +import { Middleware } from '@reduxjs/toolkit'; + +// Performance monitoring for Redux store +export interface PerformanceMetrics { + actionType: string; + duration: number; + timestamp: number; + stateSize: number; +} + +class ReduxPerformanceMonitor { + private metrics: PerformanceMetrics[] = []; + private maxMetrics = 100; // Keep last 100 metrics + private slowActionThreshold = 50; // Log actions taking more than 50ms + + logMetric(metric: PerformanceMetrics) { + this.metrics.push(metric); + + // Keep only recent metrics + if (this.metrics.length > this.maxMetrics) { + this.metrics = this.metrics.slice(-this.maxMetrics); + } + + // Log slow actions in development + if (process.env.NODE_ENV === 'development' && metric.duration > this.slowActionThreshold) { + console.warn(`Slow Redux action detected: ${metric.actionType} took ${metric.duration}ms`); + } + } + + getMetrics() { + return [...this.metrics]; + } + + getSlowActions(threshold = this.slowActionThreshold) { + return this.metrics.filter(m => m.duration > threshold); + } + + getAverageActionTime() { + if (this.metrics.length === 0) return 0; + const total = this.metrics.reduce((sum, m) => sum + m.duration, 0); + return total / this.metrics.length; + } + + reset() { + this.metrics = []; + } +} + +export const performanceMonitor = new ReduxPerformanceMonitor(); + +// Redux middleware for performance monitoring +export const performanceMiddleware: Middleware = (store) => (next) => (action: any) => { + const start = performance.now(); + + const result = next(action); + + const end = performance.now(); + const duration = end - start; + + // Calculate approximate state size (in development only) + let stateSize = 0; + if (process.env.NODE_ENV === 'development') { + try { + stateSize = JSON.stringify(store.getState()).length; + } catch (e) { + stateSize = -1; // Indicates serialization error + } + } + + performanceMonitor.logMetric({ + actionType: action.type || 'unknown', + duration, + timestamp: Date.now(), + stateSize, + }); + + return result; +}; + +// Hook to access performance metrics in components +export function useReduxPerformance() { + return { + metrics: performanceMonitor.getMetrics(), + slowActions: performanceMonitor.getSlowActions(), + averageTime: performanceMonitor.getAverageActionTime(), + reset: () => performanceMonitor.reset(), + }; +} + +// Utility to detect potential performance issues +export function analyzeReduxPerformance() { + const metrics = performanceMonitor.getMetrics(); + const analysis = { + totalActions: metrics.length, + slowActions: performanceMonitor.getSlowActions().length, + averageActionTime: performanceMonitor.getAverageActionTime(), + largestStateSize: Math.max(...metrics.map(m => m.stateSize)), + mostFrequentActions: {} as Record, + recommendations: [] as string[], + }; + + // Count action frequencies + metrics.forEach(m => { + analysis.mostFrequentActions[m.actionType] = + (analysis.mostFrequentActions[m.actionType] || 0) + 1; + }); + + // Generate recommendations + if (analysis.slowActions > analysis.totalActions * 0.1) { + analysis.recommendations.push('Consider optimizing selectors with createSelector'); + } + + if (analysis.largestStateSize > 1000000) { // 1MB + analysis.recommendations.push('State size is large - consider normalizing data'); + } + + if (analysis.averageActionTime > 20) { + analysis.recommendations.push('Average action time is high - check for expensive reducers'); + } + + return analysis; +} \ No newline at end of file diff --git a/worklenz-frontend/src/app/routes/auth-routes.tsx b/worklenz-frontend/src/app/routes/auth-routes.tsx index 2eca96a9..5cddb925 100644 --- a/worklenz-frontend/src/app/routes/auth-routes.tsx +++ b/worklenz-frontend/src/app/routes/auth-routes.tsx @@ -1,20 +1,20 @@ +import { lazy, Suspense } from 'react'; import AuthLayout from '@/layouts/AuthLayout'; -import LoginPage from '@/pages/auth/login-page'; -import SignupPage from '@/pages/auth/signup-page'; -import ForgotPasswordPage from '@/pages/auth/forgot-password-page'; -import LoggingOutPage from '@/pages/auth/logging-out'; -import AuthenticatingPage from '@/pages/auth/authenticating'; import { Navigate } from 'react-router-dom'; -import VerifyResetEmailPage from '@/pages/auth/verify-reset-email'; -import { Suspense } from 'react'; import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; +// Lazy load auth page components for better code splitting +const LoginPage = lazy(() => import('@/pages/auth/login-page')); +const SignupPage = lazy(() => import('@/pages/auth/signup-page')); +const ForgotPasswordPage = lazy(() => import('@/pages/auth/forgot-password-page')); +const LoggingOutPage = lazy(() => import('@/pages/auth/logging-out')); +const AuthenticatingPage = lazy(() => import('@/pages/auth/authenticating')); +const VerifyResetEmailPage = lazy(() => import('@/pages/auth/verify-reset-email')); + const authRoutes = [ { path: '/auth', - element: ( - - ), + element: , children: [ { path: '', @@ -22,27 +22,51 @@ const authRoutes = [ }, { path: 'login', - element: , + element: ( + }> + + + ), }, { path: 'signup', - element: , + element: ( + }> + + + ), }, { path: 'forgot-password', - element: , + element: ( + }> + + + ), }, { path: 'logging-out', - element: , + element: ( + }> + + + ), }, { path: 'authenticating', - element: , + element: ( + }> + + + ), }, { path: 'verify-reset-email/:user/:hash', - element: , + element: ( + }> + + + ), }, ], }, diff --git a/worklenz-frontend/src/app/routes/index.tsx b/worklenz-frontend/src/app/routes/index.tsx index 7d6d6826..d9361804 100644 --- a/worklenz-frontend/src/app/routes/index.tsx +++ b/worklenz-frontend/src/app/routes/index.tsx @@ -1,4 +1,5 @@ import { createBrowserRouter, Navigate, RouteObject, useLocation } from 'react-router-dom'; +import { lazy, Suspense, memo, useMemo } from 'react'; import rootRoutes from './root-routes'; import authRoutes from './auth-routes'; import mainRoutes, { licenseExpiredRoute } from './main-routes'; @@ -8,116 +9,195 @@ import reportingRoutes from './reporting-routes'; import { useAuthService } from '@/hooks/useAuth'; import { AuthenticatedLayout } from '@/layouts/AuthenticatedLayout'; import ErrorBoundary from '@/components/ErrorBoundary'; -import NotFoundPage from '@/pages/404-page/404-page'; +import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; -import LicenseExpired from '@/pages/license-expired/license-expired'; + +// Lazy load the NotFoundPage component for better code splitting +const NotFoundPage = lazy(() => import('@/pages/404-page/404-page')); interface GuardProps { children: React.ReactNode; } -export const AuthGuard = ({ children }: GuardProps) => { - const isAuthenticated = useAuthService().isAuthenticated(); - const location = useLocation(); - - if (!isAuthenticated) { - return ; - } - - return <>{children}; +// Route-based code splitting utility +const withCodeSplitting = (Component: React.LazyExoticComponent>) => { + return memo(() => ( + }> + + + )); }; -export const AdminGuard = ({ children }: GuardProps) => { - const isAuthenticated = useAuthService().isAuthenticated(); - const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); - const currentSession = useAuthService().getCurrentSession(); - const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE; +// Memoized guard components with defensive programming +export const AuthGuard = memo(({ children }: GuardProps) => { + const authService = useAuthService(); const location = useLocation(); - if (!isAuthenticated) { - return ; - } - - if (!isOwnerOrAdmin || isFreePlan) { - return ; - } - - return <>{children}; -}; - -export const LicenseExpiryGuard = ({ children }: GuardProps) => { - const isAuthenticated = useAuthService().isAuthenticated(); - const currentSession = useAuthService().getCurrentSession(); - const location = useLocation(); - const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center'); - const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired'; - - // Don't check or redirect if we're already on the license-expired page - if (isLicenseExpiredRoute) { - return <>{children}; - } - - // Check if trial is expired more than 7 days or if is_expired flag is set - const isLicenseExpiredMoreThan7Days = () => { - // Quick bail if no session data is available - if (!currentSession) { - return false; - } - - // Check is_expired flag first - if (currentSession.is_expired) { - // If no trial_expire_date exists but is_expired is true, defer to backend check - if (!currentSession.trial_expire_date) { - return true; + const shouldRedirect = useMemo(() => { + try { + // Defensive check to ensure authService and its methods exist + if (!authService || typeof authService.isAuthenticated !== 'function') { + return false; // Don't redirect if auth service is not ready } - - // If there is a trial_expire_date, check if it's more than 7 days past - const today = new Date(); - const expiryDate = new Date(currentSession.trial_expire_date); - const diffTime = today.getTime() - expiryDate.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - // Redirect if more than 7 days past expiration - return diffDays > 7; + return !authService.isAuthenticated(); + } catch (error) { + console.error('Error in AuthGuard:', error); + return false; // Don't redirect on error, let the app handle it } - - // If not marked as expired but has trial_expire_date, do a date check - if (currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && currentSession.trial_expire_date) { - const today = new Date(); - const expiryDate = new Date(currentSession.trial_expire_date); + }, [authService]); - const diffTime = today.getTime() - expiryDate.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + if (shouldRedirect) { + return ; + } + + return <>{children}; +}); + +AuthGuard.displayName = 'AuthGuard'; + +export const AdminGuard = memo(({ children }: GuardProps) => { + const authService = useAuthService(); + const location = useLocation(); + + const guardResult = useMemo(() => { + try { + // Defensive checks to ensure authService and its methods exist + if (!authService || + typeof authService.isAuthenticated !== 'function' || + typeof authService.isOwnerOrAdmin !== 'function' || + typeof authService.getCurrentSession !== 'function') { + return null; // Don't redirect if auth service is not ready + } + + if (!authService.isAuthenticated()) { + return { redirect: '/auth', state: { from: location } }; + } + + const currentSession = authService.getCurrentSession(); + const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE; - // If expired more than 7 days, redirect - return diffDays > 7; - } - - // No expiration data found - return false; - }; + if (!authService.isOwnerOrAdmin() || isFreePlan) { + return { redirect: '/worklenz/unauthorized' }; + } + + return null; + } catch (error) { + console.error('Error in AdminGuard:', error); + return null; // Don't redirect on error + } + }, [authService, location]); + + if (guardResult) { + return ; + } + + return <>{children}; +}); + +AdminGuard.displayName = 'AdminGuard'; + +export const LicenseExpiryGuard = memo(({ children }: GuardProps) => { + const authService = useAuthService(); + const location = useLocation(); + + const shouldRedirect = useMemo(() => { + try { + // Defensive checks to ensure authService and its methods exist + if (!authService || + typeof authService.isAuthenticated !== 'function' || + typeof authService.getCurrentSession !== 'function') { + return false; // Don't redirect if auth service is not ready + } + + if (!authService.isAuthenticated()) return false; + + const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center'); + const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired'; + + // Don't check or redirect if we're already on the license-expired page + if (isLicenseExpiredRoute) return false; + + const currentSession = authService.getCurrentSession(); + + // Check if trial is expired more than 7 days or if is_expired flag is set + const isLicenseExpiredMoreThan7Days = () => { + // Quick bail if no session data is available + if (!currentSession) return false; + + // Check is_expired flag first + if (currentSession.is_expired) { + // If no trial_expire_date exists but is_expired is true, defer to backend check + if (!currentSession.trial_expire_date) return true; + + // If there is a trial_expire_date, check if it's more than 7 days past + const today = new Date(); + const expiryDate = new Date(currentSession.trial_expire_date); + const diffTime = today.getTime() - expiryDate.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + // Redirect if more than 7 days past expiration + return diffDays > 7; + } + + // If not marked as expired but has trial_expire_date, do a date check + if (currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && currentSession.trial_expire_date) { + const today = new Date(); + const expiryDate = new Date(currentSession.trial_expire_date); + + const diffTime = today.getTime() - expiryDate.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + // If expired more than 7 days, redirect + return diffDays > 7; + } + + // No expiration data found + return false; + }; + + return isLicenseExpiredMoreThan7Days() && !isAdminCenterRoute; + } catch (error) { + console.error('Error in LicenseExpiryGuard:', error); + return false; // Don't redirect on error + } + }, [authService, location.pathname]); - // Add this explicit check and log the result - const shouldRedirect = isAuthenticated && isLicenseExpiredMoreThan7Days() && !isAdminCenterRoute; if (shouldRedirect) { return ; } return <>{children}; -}; +}); -export const SetupGuard = ({ children }: GuardProps) => { - const isAuthenticated = useAuthService().isAuthenticated(); +LicenseExpiryGuard.displayName = 'LicenseExpiryGuard'; + +export const SetupGuard = memo(({ children }: GuardProps) => { + const authService = useAuthService(); const location = useLocation(); - if (!isAuthenticated) { + const shouldRedirect = useMemo(() => { + try { + // Defensive check to ensure authService and its methods exist + if (!authService || typeof authService.isAuthenticated !== 'function') { + return false; // Don't redirect if auth service is not ready + } + return !authService.isAuthenticated(); + } catch (error) { + console.error('Error in SetupGuard:', error); + return false; // Don't redirect on error + } + }, [authService]); + + if (shouldRedirect) { return ; } return <>{children}; -}; +}); -// Helper to wrap routes with guards +SetupGuard.displayName = 'SetupGuard'; + +// Optimized route wrapping function with Suspense boundaries const wrapRoutes = ( routes: RouteObject[], Guard: React.ComponentType<{ children: React.ReactNode }> @@ -125,7 +205,11 @@ const wrapRoutes = ( return routes.map(route => { const wrappedRoute = { ...route, - element: {route.element}, + element: ( + }> + {route.element} + + ), }; if (route.children) { @@ -140,9 +224,8 @@ const wrapRoutes = ( }); }; -// Static license expired component that doesn't rely on translations or authentication -const StaticLicenseExpired = () => { - +// Optimized static license expired component +const StaticLicenseExpired = memo(() => { return (
{
); -}; +}); +StaticLicenseExpired.displayName = 'StaticLicenseExpired'; + +// Create route arrays (moved outside of useMemo to avoid hook violations) const publicRoutes = [ ...rootRoutes, ...authRoutes, notFoundRoute ]; + const protectedMainRoutes = wrapRoutes(mainRoutes, AuthGuard); const adminRoutes = wrapRoutes(reportingRoutes, AdminGuard); const setupRoutes = wrapRoutes([accountSetupRoute], SetupGuard); -// Apply LicenseExpiryGuard to all protected routes +// License expiry check function const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => { return routes.map(route => { const wrappedRoute = { ...route, - element: {route.element}, + element: ( + }> + {route.element} + + ), }; if (route.children) { @@ -213,10 +304,21 @@ const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => { const licenseCheckedMainRoutes = withLicenseExpiryCheck(protectedMainRoutes); +// Create optimized router with future flags for better performance const router = createBrowserRouter([ { - element: , - errorElement: , + element: ( + + + + ), + errorElement: ( + + }> + + + + ), children: [ ...licenseCheckedMainRoutes, ...adminRoutes, @@ -225,6 +327,15 @@ const router = createBrowserRouter([ ], }, ...publicRoutes, -]); +], { + // Enable React Router future features for better performance + future: { + v7_relativeSplatPath: true, + v7_fetcherPersist: true, + v7_normalizeFormMethod: true, + v7_partialHydration: true, + v7_skipActionErrorRevalidation: true + } +}); export default router; diff --git a/worklenz-frontend/src/app/routes/main-routes.tsx b/worklenz-frontend/src/app/routes/main-routes.tsx index 8647fac2..225fd9a7 100644 --- a/worklenz-frontend/src/app/routes/main-routes.tsx +++ b/worklenz-frontend/src/app/routes/main-routes.tsx @@ -1,32 +1,49 @@ import { RouteObject } from 'react-router-dom'; +import { lazy, Suspense } from 'react'; import MainLayout from '@/layouts/MainLayout'; -import HomePage from '@/pages/home/home-page'; -import ProjectList from '@/pages/projects/project-list'; import settingsRoutes from './settings-routes'; import adminCenterRoutes from './admin-center-routes'; -import Schedule from '@/pages/schedule/schedule'; -import ProjectTemplateEditView from '@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView'; -import LicenseExpired from '@/pages/license-expired/license-expired'; -import ProjectView from '@/pages/projects/projectView/project-view'; -import Unauthorized from '@/pages/unauthorized/unauthorized'; import { useAuthService } from '@/hooks/useAuth'; import { Navigate, useLocation } from 'react-router-dom'; +import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; -// Define AdminGuard component first +// Lazy load page components for better code splitting +const HomePage = lazy(() => import('@/pages/home/home-page')); +const ProjectList = lazy(() => import('@/pages/projects/project-list')); +const Schedule = lazy(() => import('@/pages/schedule/schedule')); +const ProjectTemplateEditView = lazy(() => import('@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView')); +const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired')); +const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view')); +const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized')); + +// Define AdminGuard component with defensive programming const AdminGuard = ({ children }: { children: React.ReactNode }) => { - const isAuthenticated = useAuthService().isAuthenticated(); - const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); + const authService = useAuthService(); const location = useLocation(); - if (!isAuthenticated) { - return ; - } + try { + // Defensive checks to ensure authService and its methods exist + if (!authService || + typeof authService.isAuthenticated !== 'function' || + typeof authService.isOwnerOrAdmin !== 'function') { + // If auth service is not ready, render children (don't block) + return <>{children}; + } - if (!isOwnerOrAdmin) { - return ; - } + if (!authService.isAuthenticated()) { + return ; + } - return <>{children}; + if (!authService.isOwnerOrAdmin()) { + return ; + } + + return <>{children}; + } catch (error) { + console.error('Error in AdminGuard (main-routes):', error); + // On error, render children to prevent complete blocking + return <>{children}; + } }; const mainRoutes: RouteObject[] = [ @@ -35,18 +52,56 @@ const mainRoutes: RouteObject[] = [ element: , children: [ { index: true, element: }, - { path: 'home', element: }, - { path: 'projects', element: }, + { + path: 'home', + element: ( + }> + + + ) + }, + { + path: 'projects', + element: ( + }> + + + ) + }, { path: 'schedule', - element: + element: ( + }> + + + + + ) + }, + { + path: `projects/:projectId`, + element: ( + }> + + + ) }, - { path: `projects/:projectId`, element: }, { path: `settings/project-templates/edit/:templateId/:templateName`, - element: , + element: ( + }> + + + ), + }, + { + path: 'unauthorized', + element: ( + }> + + + ) }, - { path: 'unauthorized', element: }, ...settingsRoutes, ...adminCenterRoutes, ], @@ -58,7 +113,14 @@ export const licenseExpiredRoute: RouteObject = { path: '/worklenz', element: , children: [ - { path: 'license-expired', element: } + { + path: 'license-expired', + element: ( + }> + + + ) + } ] }; diff --git a/worklenz-frontend/src/app/selectors.ts b/worklenz-frontend/src/app/selectors.ts new file mode 100644 index 00000000..29cbd3be --- /dev/null +++ b/worklenz-frontend/src/app/selectors.ts @@ -0,0 +1,81 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { RootState } from './store'; + +// Memoized selectors for better performance +// These prevent unnecessary re-renders when state hasn't actually changed + +// Auth selectors +export const selectAuth = (state: RootState) => state.auth; +export const selectUser = (state: RootState) => state.userReducer; +export const selectIsAuthenticated = createSelector( + [selectAuth], + (auth) => !!auth.user +); + +// Project selectors +export const selectProjects = (state: RootState) => state.projectsReducer; +export const selectCurrentProject = (state: RootState) => state.projectReducer; +export const selectProjectMembers = (state: RootState) => state.projectMemberReducer; + +// Task selectors +export const selectTasks = (state: RootState) => state.taskReducer; +export const selectTaskManagement = (state: RootState) => state.taskManagement; +export const selectTaskSelection = (state: RootState) => state.taskManagementSelection; + +// UI State selectors +export const selectTheme = (state: RootState) => state.themeReducer; +export const selectLocale = (state: RootState) => state.localesReducer; +export const selectAlerts = (state: RootState) => state.alertsReducer; + +// Board and Project View selectors +export const selectBoard = (state: RootState) => state.boardReducer; +export const selectProjectView = (state: RootState) => state.projectViewReducer; +export const selectProjectDrawer = (state: RootState) => state.projectDrawerReducer; + +// Task attributes selectors +export const selectTaskPriorities = (state: RootState) => state.priorityReducer; +export const selectTaskLabels = (state: RootState) => state.taskLabelsReducer; +export const selectTaskStatuses = (state: RootState) => state.taskStatusReducer; +export const selectTaskDrawer = (state: RootState) => state.taskDrawerReducer; + +// Settings selectors +export const selectMembers = (state: RootState) => state.memberReducer; +export const selectClients = (state: RootState) => state.clientReducer; +export const selectJobs = (state: RootState) => state.jobReducer; +export const selectTeams = (state: RootState) => state.teamReducer; +export const selectCategories = (state: RootState) => state.categoriesReducer; +export const selectLabels = (state: RootState) => state.labelReducer; + +// Reporting selectors +export const selectReporting = (state: RootState) => state.reportingReducer; +export const selectProjectReports = (state: RootState) => state.projectReportsReducer; +export const selectMemberReports = (state: RootState) => state.membersReportsReducer; +export const selectTimeReports = (state: RootState) => state.timeReportsOverviewReducer; + +// Admin and billing selectors +export const selectAdminCenter = (state: RootState) => state.adminCenterReducer; +export const selectBilling = (state: RootState) => state.billingReducer; + +// Schedule and date selectors +export const selectSchedule = (state: RootState) => state.scheduleReducer; +export const selectDate = (state: RootState) => state.dateReducer; + +// Feature-specific selectors +export const selectHomePage = (state: RootState) => state.homePageReducer; +export const selectAccountSetup = (state: RootState) => state.accountSetupReducer; +export const selectRoadmap = (state: RootState) => state.roadmapReducer; +export const selectGroupByFilter = (state: RootState) => state.groupByFilterDropdownReducer; + +// Memoized computed selectors for common use cases +export const selectHasActiveProject = createSelector( + [selectCurrentProject], + (project) => !!project && Object.keys(project).length > 0 +); + +export const selectIsLoading = createSelector( + [selectTasks, selectProjects], + (tasks, projects) => { + // Check if any major feature is loading + return (tasks as any)?.loading || (projects as any)?.loading; + } +); \ No newline at end of file diff --git a/worklenz-frontend/src/components/suspense-fallback/suspense-fallback.tsx b/worklenz-frontend/src/components/suspense-fallback/suspense-fallback.tsx index 041fe5a4..0354849b 100644 --- a/worklenz-frontend/src/components/suspense-fallback/suspense-fallback.tsx +++ b/worklenz-frontend/src/components/suspense-fallback/suspense-fallback.tsx @@ -1,46 +1,50 @@ +import React, { memo } from 'react'; import { colors } from '@/styles/colors'; import { getInitialTheme } from '@/utils/get-initial-theme'; import { ConfigProvider, theme, Layout, Spin } from 'antd'; -// Loading component with theme awareness -export const SuspenseFallback = () => { +// Memoized loading component with theme awareness +export const SuspenseFallback = memo(() => { const currentTheme = getInitialTheme(); const isDark = currentTheme === 'dark'; + // Memoize theme configuration to prevent unnecessary re-renders + const themeConfig = React.useMemo(() => ({ + algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm, + components: { + Layout: { + colorBgLayout: isDark ? colors.darkGray : '#fafafa', + }, + Spin: { + colorPrimary: isDark ? '#fff' : '#1890ff', + }, + }, + }), [isDark]); + + // Memoize layout style to prevent object recreation + const layoutStyle = React.useMemo(() => ({ + position: 'fixed' as const, + width: '100vw', + height: '100vh', + background: 'transparent', + transition: 'none', + }), []); + + // Memoize spin style to prevent object recreation + const spinStyle = React.useMemo(() => ({ + position: 'absolute' as const, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }), []); + return ( - - - + + + ); -}; +}); + +SuspenseFallback.displayName = 'SuspenseFallback'; diff --git a/worklenz-frontend/src/components/task-management/BulkActionBar.tsx b/worklenz-frontend/src/components/task-management/BulkActionBar.tsx deleted file mode 100644 index 5f4cba8f..00000000 --- a/worklenz-frontend/src/components/task-management/BulkActionBar.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import React from 'react'; -import { Card, Button, Space, Typography, Dropdown, Menu, Popconfirm, message } from 'antd'; -import { - DeleteOutlined, - EditOutlined, - TagOutlined, - UserOutlined, - CheckOutlined, - CloseOutlined, - MoreOutlined, -} from '@ant-design/icons'; -import { useDispatch, useSelector } from 'react-redux'; -import { IGroupBy, bulkUpdateTasks, bulkDeleteTasks } from '@/features/tasks/tasks.slice'; -import { AppDispatch, RootState } from '@/app/store'; - -const { Text } = Typography; - -interface BulkActionBarProps { - selectedTaskIds: string[]; - totalSelected: number; - currentGrouping: IGroupBy; - projectId: string; - onClearSelection?: () => void; -} - -const BulkActionBar: React.FC = ({ - selectedTaskIds, - totalSelected, - currentGrouping, - projectId, - onClearSelection, -}) => { - const dispatch = useDispatch(); - const { statuses, priorities } = useSelector((state: RootState) => state.taskReducer); - - const handleBulkStatusChange = (statusId: string) => { - // dispatch(bulkUpdateTasks({ ids: selectedTaskIds, changes: { status: statusId } })); - message.success(`Updated ${totalSelected} tasks`); - onClearSelection?.(); - }; - - const handleBulkPriorityChange = (priority: string) => { - // dispatch(bulkUpdateTasks({ ids: selectedTaskIds, changes: { priority } })); - message.success(`Updated ${totalSelected} tasks`); - onClearSelection?.(); - }; - - const handleBulkDelete = () => { - // dispatch(bulkDeleteTasks(selectedTaskIds)); - message.success(`Deleted ${totalSelected} tasks`); - onClearSelection?.(); - }; - - const statusMenu = ( - handleBulkStatusChange(key)} - items={statuses.map(status => ({ - key: status.id!, - label: ( -
-
- {status.name} -
- ), - }))} - /> - ); - - const priorityMenu = ( - handleBulkPriorityChange(key)} - items={[ - { key: 'critical', label: 'Critical', icon:
}, - { key: 'high', label: 'High', icon:
}, - { key: 'medium', label: 'Medium', icon:
}, - { key: 'low', label: 'Low', icon:
}, - ]} - /> - ); - - const moreActionsMenu = ( - , - }, - { - key: 'labels', - label: 'Add labels', - icon: , - }, - { - key: 'archive', - label: 'Archive tasks', - icon: , - }, - ]} - /> - ); - - return ( - -
-
- - {totalSelected} task{totalSelected > 1 ? 's' : ''} selected - -
- - - {/* Status Change */} - {currentGrouping !== 'status' && ( - - - - )} - - {/* Priority Change */} - {currentGrouping !== 'priority' && ( - - - - )} - - {/* More Actions */} - - - - - {/* Delete */} - 1 ? 's' : ''}?`} - description="This action cannot be undone." - onConfirm={handleBulkDelete} - okText="Delete" - cancelText="Cancel" - okType="danger" - > - - - - {/* Clear Selection */} - - -
-
- ); -}; - -export default BulkActionBar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/bulk-action-bar.tsx b/worklenz-frontend/src/components/task-management/bulk-action-bar.tsx new file mode 100644 index 00000000..1148e623 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/bulk-action-bar.tsx @@ -0,0 +1,590 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Button, Typography, Dropdown, Menu, Popconfirm, message, Tooltip, Badge, CheckboxChangeEvent, InputRef } from 'antd'; +import { + DeleteOutlined, + EditOutlined, + TagOutlined, + UserOutlined, + CheckOutlined, + CloseOutlined, + MoreOutlined, + RetweetOutlined, + UserAddOutlined, + InboxOutlined, + TagsOutlined, + UsergroupAddOutlined, +} from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { IGroupBy, fetchTaskGroups } from '@/features/tasks/tasks.slice'; +import { AppDispatch, RootState } from '@/app/store'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; +import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service'; +import { + evt_project_task_list_bulk_archive, + evt_project_task_list_bulk_assign_me, + evt_project_task_list_bulk_assign_members, + evt_project_task_list_bulk_change_phase, + evt_project_task_list_bulk_change_priority, + evt_project_task_list_bulk_change_status, + evt_project_task_list_bulk_delete, + evt_project_task_list_bulk_update_labels, +} from '@/shared/worklenz-analytics-events'; +import { + IBulkTasksLabelsRequest, + IBulkTasksPhaseChangeRequest, + IBulkTasksPriorityChangeRequest, + IBulkTasksStatusChangeRequest, +} from '@/types/tasks/bulk-action-bar.types'; +import { ITaskStatus } from '@/types/tasks/taskStatus.types'; +import { ITaskPriority } from '@/types/tasks/taskPriority.types'; +import { ITaskPhase } from '@/types/tasks/taskPhase.types'; +import { ITaskLabel } from '@/types/tasks/taskLabel.types'; +import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types'; +import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types'; +import { ITaskAssignee } from '@/types/tasks/task.types'; +import { createPortal } from 'react-dom'; +import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer'; +import AssigneesDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/AssigneesDropdown'; +import LabelsDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown'; +import { sortTeamMembers } from '@/utils/sort-team-members'; +import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; +import { useAuthService } from '@/hooks/useAuth'; +import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; +import alertService from '@/services/alerts/alertService'; +import logger from '@/utils/errorLogger'; + +const { Text } = Typography; + +interface BulkActionBarProps { + selectedTaskIds: string[]; + totalSelected: number; + currentGrouping: IGroupBy; + projectId: string; + onClearSelection?: () => void; +} + +const BulkActionBarContent: React.FC = ({ + selectedTaskIds, + totalSelected, + currentGrouping, + projectId, + onClearSelection, +}) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation('tasks/task-table-bulk-actions'); + const { trackMixpanelEvent } = useMixpanelTracking(); + + // Add permission hooks + const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); + + // loading state + const [loading, setLoading] = useState(false); + const [updatingLabels, setUpdatingLabels] = useState(false); + const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false); + const [updatingAssignees, setUpdatingAssignees] = useState(false); + const [updatingArchive, setUpdatingArchive] = useState(false); + const [updatingDelete, setUpdatingDelete] = useState(false); + + // Selectors + const { selectedTaskIdsList } = useAppSelector(state => state.bulkActionReducer); + const statusList = useAppSelector(state => state.taskStatusReducer.status); + const priorityList = useAppSelector(state => state.priorityReducer.priorities); + const phaseList = useAppSelector(state => state.phaseReducer.phaseList); + const labelsList = useAppSelector(state => state.taskLabelsReducer.labels); + const members = useAppSelector(state => state.teamMembersReducer.teamMembers); + const archived = useAppSelector(state => state.taskReducer.archived); + const themeMode = useAppSelector(state => state.themeReducer.mode); + + const labelsInputRef = useRef(null); + const [createLabelText, setCreateLabelText] = useState(''); + const [teamMembersSorted, setTeamMembersSorted] = useState({ + data: [], + total: 0, + }); + const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false); + const [showDrawer, setShowDrawer] = useState(false); + const [selectedLabels, setSelectedLabels] = useState([]); + + // Handlers + const handleChangeStatus = async (status: ITaskStatus) => { + if (!status.id || !projectId) return; + try { + setLoading(true); + + const body: IBulkTasksStatusChangeRequest = { + tasks: selectedTaskIds, + status_id: status.id, + }; + const res = await taskListBulkActionsApiService.changeStatus(body, projectId); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_change_status); + dispatch(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + for (const it of selectedTaskIds) { + const canContinue = await checkTaskDependencyStatus(it, status.id); + if (!canContinue) { + if (selectedTaskIds.length > 1) { + alertService.warning( + 'Incomplete Dependencies!', + 'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.' + ); + } else { + alertService.error( + 'Task is not completed', + 'Please complete the task dependencies before proceeding' + ); + } + return; + } + } + } catch (error) { + logger.error('Error changing status:', error); + } finally { + setLoading(false); + } + }; + + const handleChangePriority = async (priority: ITaskPriority) => { + if (!priority.id || !projectId) return; + try { + setLoading(true); + const body: IBulkTasksPriorityChangeRequest = { + tasks: selectedTaskIds, + priority_id: priority.id, + }; + const res = await taskListBulkActionsApiService.changePriority(body, projectId); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_change_priority); + dispatch(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error changing priority:', error); + } finally { + setLoading(false); + } + }; + + const handleChangePhase = async (phase: ITaskPhase) => { + if (!phase.id || !projectId) return; + try { + setLoading(true); + const body: IBulkTasksPhaseChangeRequest = { + tasks: selectedTaskIds, + phase_id: phase.id, + }; + const res = await taskListBulkActionsApiService.changePhase(body, projectId); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_change_phase); + dispatch(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error changing phase:', error); + } finally { + setLoading(false); + } + }; + + const handleAssignToMe = async () => { + if (!projectId) return; + try { + setUpdatingAssignToMe(true); + const body = { + tasks: selectedTaskIds, + project_id: projectId, + }; + const res = await taskListBulkActionsApiService.assignToMe(body); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_assign_me); + dispatch(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error assigning to me:', error); + } finally { + setUpdatingAssignToMe(false); + } + }; + + const handleArchive = async () => { + if (!projectId) return; + try { + setUpdatingArchive(true); + const body = { + tasks: selectedTaskIds, + project_id: projectId, + }; + const res = await taskListBulkActionsApiService.archiveTasks(body, archived); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_archive); + dispatch(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error archiving tasks:', error); + } finally { + setUpdatingArchive(false); + } + }; + + const handleChangeAssignees = async (selectedAssignees: ITeamMemberViewModel[]) => { + if (!projectId) return; + try { + setUpdatingAssignees(true); + const body = { + tasks: selectedTaskIds, + project_id: projectId, + members: selectedAssignees.map(member => ({ + id: member.id, + name: member.name || member.email || 'Unknown', // Fix: Ensure name is always a string + email: member.email || '', + avatar_url: member.avatar_url, + team_member_id: member.id, + project_member_id: member.id, + })) as ITaskAssignee[], + }; + const res = await taskListBulkActionsApiService.assignTasks(body); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_assign_members); + dispatch(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error assigning tasks:', error); + } finally { + setUpdatingAssignees(false); + } + }; + + const handleDelete = async () => { + if (!projectId) return; + try { + setUpdatingDelete(true); + const body = { + tasks: selectedTaskIds, + project_id: projectId, + }; + const res = await taskListBulkActionsApiService.deleteTasks(body, projectId); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_delete); + dispatch(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error deleting tasks:', error); + } finally { + setUpdatingDelete(false); + } + }; + + // Menu Generators + const getChangeOptionsMenu = () => [ + { + key: '1', + label: t('status'), + children: statusList.map(status => ({ + key: status.id, + onClick: () => handleChangeStatus(status), + label: , + })), + }, + { + key: '2', + label: t('priority'), + children: priorityList.map(priority => ({ + key: priority.id, + onClick: () => handleChangePriority(priority), + label: , + })), + }, + { + key: '3', + label: t('phase'), + children: phaseList.map(phase => ({ + key: phase.id, + onClick: () => handleChangePhase(phase), + label: , + })), + }, + ]; + + useEffect(() => { + if (members?.data && assigneeDropdownOpen) { + let sortedMembers = sortTeamMembers(members.data); + setTeamMembersSorted({ data: sortedMembers, total: members.total }); + } + }, [assigneeDropdownOpen, members?.data]); + + const getAssigneesMenu = () => { + return ( + setAssigneeDropdownOpen(false)} + t={t} + /> + ); + }; + + const handleLabelChange = (e: CheckboxChangeEvent, label: ITaskLabel) => { + if (e.target.checked) { + setSelectedLabels(prev => [...prev, label]); + } else { + setSelectedLabels(prev => prev.filter(l => l.id !== label.id)); + } + }; + + const applyLabels = async () => { + if (!projectId) return; + try { + setUpdatingLabels(true); + const body: IBulkTasksLabelsRequest = { + tasks: selectedTaskIds, + labels: selectedLabels, + text: + selectedLabels.length > 0 + ? null + : createLabelText.trim() !== '' + ? createLabelText.trim() + : null, + }; + const res = await taskListBulkActionsApiService.assignLabels(body, projectId); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_update_labels); + dispatch(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + dispatch(fetchLabels()); // Fallback: refetch all labels + setCreateLabelText(''); + setSelectedLabels([]); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error updating labels:', error); + } finally { + setUpdatingLabels(false); + } + }; + + const labelsDropdownContent = ( + } + onLabelChange={handleLabelChange} + onCreateLabelTextChange={value => setCreateLabelText(value)} + onApply={applyLabels} + t={t} + loading={updatingLabels} + /> + ); + + const onAssigneeDropdownOpenChange = (open: boolean) => { + setAssigneeDropdownOpen(open); + }; + + const buttonStyle = { + background: 'transparent', + color: '#fff', + border: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '4px 8px', + height: '32px', + fontSize: '16px', + }; + + return ( +
+ + {totalSelected} task{totalSelected > 1 ? 's' : ''} selected + + + {/* Status/Priority/Phase Change */} + + +
+ ); +}; + +const BulkActionBar: React.FC = (props) => { + // Render the bulk action bar through a portal to avoid suspense issues + return createPortal( + , + document.body, + 'bulk-action-bar' + ); +}; + +export default BulkActionBar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/GroupingSelector.tsx b/worklenz-frontend/src/components/task-management/grouping-selector.tsx similarity index 100% rename from worklenz-frontend/src/components/task-management/GroupingSelector.tsx rename to worklenz-frontend/src/components/task-management/grouping-selector.tsx diff --git a/worklenz-frontend/src/components/task-management/TaskGroup.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx similarity index 97% rename from worklenz-frontend/src/components/task-management/TaskGroup.tsx rename to worklenz-frontend/src/components/task-management/task-group.tsx index 9a3fbb1d..dff7eb74 100644 --- a/worklenz-frontend/src/components/task-management/TaskGroup.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -8,7 +8,7 @@ import { ITaskListGroup } from '@/types/tasks/taskList.types'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice'; import { RootState } from '@/app/store'; -import TaskRow from './TaskRow'; +import TaskRow from './task-row'; import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row'; const { Text } = Typography; @@ -126,7 +126,10 @@ const TaskGroup: React.FC = ({ {/* Column Headers */} {!isCollapsed && totalTasks > 0 && ( -
+
= ({ {/* Tasks List */} {!isCollapsed && ( -
+
{group.tasks.length === 0 ? (
@@ -262,7 +268,7 @@ const TaskGroup: React.FC = ({ display: inline-flex; align-items: center; padding: 8px 12px; - border-radius: 6px; + border-radius: 6px 6px 0 0; background-color: #f0f0f0; color: white; font-weight: 500; diff --git a/worklenz-frontend/src/components/task-management/TaskListBoard.tsx b/worklenz-frontend/src/components/task-management/task-list-board.tsx similarity index 98% rename from worklenz-frontend/src/components/task-management/TaskListBoard.tsx rename to worklenz-frontend/src/components/task-management/task-list-board.tsx index 96d92085..60367f0b 100644 --- a/worklenz-frontend/src/components/task-management/TaskListBoard.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -24,9 +24,9 @@ import { reorderTasks, } from '@/features/tasks/tasks.slice'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import TaskGroup from './TaskGroup'; -import TaskRow from './TaskRow'; -import BulkActionBar from './BulkActionBar'; +import TaskGroup from './task-group'; +import TaskRow from './task-row'; +import BulkActionBar from './bulk-action-bar'; import { AppDispatch } from '@/app/store'; // Import the TaskListFilters component @@ -242,6 +242,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' totalSelected={selectedTaskIds.length} currentGrouping={groupBy} projectId={projectId} + onClearSelection={() => setSelectedTaskIds([])} /> )} diff --git a/worklenz-frontend/src/components/task-management/TaskRow.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx similarity index 100% rename from worklenz-frontend/src/components/task-management/TaskRow.tsx rename to worklenz-frontend/src/components/task-management/task-row.tsx diff --git a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx index 5ac8b726..c8043046 100644 --- a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx +++ b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx @@ -247,7 +247,7 @@ const TaskListBulkActionsBar = () => { project_id: projectId, members: selectedAssignees.map(member => ({ id: member.id, - name: member.name, + name: member.name || '', email: member.email, avatar_url: member.avatar_url, })) as ITaskAssignee[], @@ -437,7 +437,6 @@ const TaskListBulkActionsBar = () => { placement="top" arrow trigger={['click']} - destroyOnHidden onOpenChange={value => { if (!value) { setSelectedLabels([]); diff --git a/worklenz-frontend/src/features/theme/ThemeWrapper.tsx b/worklenz-frontend/src/features/theme/ThemeWrapper.tsx index 9e6ec612..d46d08da 100644 --- a/worklenz-frontend/src/features/theme/ThemeWrapper.tsx +++ b/worklenz-frontend/src/features/theme/ThemeWrapper.tsx @@ -1,5 +1,5 @@ import { ConfigProvider, theme } from 'antd'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, memo, useMemo, useCallback } from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { initializeTheme } from './themeSlice'; @@ -9,12 +9,45 @@ type ChildrenProp = { children: React.ReactNode; }; -const ThemeWrapper = ({ children }: ChildrenProp) => { +const ThemeWrapper = memo(({ children }: ChildrenProp) => { const dispatch = useAppDispatch(); const themeMode = useAppSelector(state => state.themeReducer.mode); const isInitialized = useAppSelector(state => state.themeReducer.isInitialized); const configRef = useRef(null); + // Memoize theme configuration to prevent unnecessary re-renders + const themeConfig = useMemo(() => ({ + algorithm: themeMode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm, + components: { + Layout: { + colorBgLayout: themeMode === 'dark' ? colors.darkGray : colors.white, + headerBg: themeMode === 'dark' ? colors.darkGray : colors.white, + }, + Menu: { + colorBgContainer: colors.transparent, + }, + Table: { + rowHoverBg: themeMode === 'dark' ? '#000' : '#edebf0', + }, + Select: { + controlHeight: 32, + }, + }, + token: { + borderRadius: 4, + }, + }), [themeMode]); + + // Memoize the theme class name + const themeClassName = useMemo(() => `theme-${themeMode}`, [themeMode]); + + // Memoize the media query change handler + const handleMediaQueryChange = useCallback((e: MediaQueryListEvent) => { + if (!localStorage.getItem('theme')) { + dispatch(initializeTheme()); + } + }, [dispatch]); + // Initialize theme after mount useEffect(() => { if (!isInitialized) { @@ -26,15 +59,9 @@ const ThemeWrapper = ({ children }: ChildrenProp) => { useEffect(() => { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - const handleChange = (e: MediaQueryListEvent) => { - if (!localStorage.getItem('theme')) { - dispatch(initializeTheme()); - } - }; - - mediaQuery.addEventListener('change', handleChange); - return () => mediaQuery.removeEventListener('change', handleChange); - }, [dispatch]); + mediaQuery.addEventListener('change', handleMediaQueryChange); + return () => mediaQuery.removeEventListener('change', handleMediaQueryChange); + }, [handleMediaQueryChange]); // Add CSS transition classes to prevent flash useEffect(() => { @@ -44,34 +71,14 @@ const ThemeWrapper = ({ children }: ChildrenProp) => { }, []); return ( -
- +
+ {children}
); -}; +}); + +ThemeWrapper.displayName = 'ThemeWrapper'; export default ThemeWrapper; diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index 83a4f4c4..024b2857 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -1,60 +1,74 @@ import { Col, ConfigProvider, Layout } from 'antd'; import { Outlet, useNavigate } from 'react-router-dom'; +import { useEffect, memo, useMemo, useCallback } from 'react'; +import { useMediaQuery } from 'react-responsive'; + import Navbar from '../features/navbar/navbar'; import { useAppSelector } from '../hooks/useAppSelector'; -import { useMediaQuery } from 'react-responsive'; +import { useAppDispatch } from '../hooks/useAppDispatch'; import { colors } from '../styles/colors'; import { verifyAuthentication } from '@/features/auth/authSlice'; -import { useEffect } from 'react'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useRenderPerformance } from '@/utils/performance'; import HubSpot from '@/components/HubSpot'; -const MainLayout = () => { +const MainLayout = memo(() => { const themeMode = useAppSelector(state => state.themeReducer.mode); const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' }); const dispatch = useAppDispatch(); const navigate = useNavigate(); - const verifyAuthStatus = async () => { + // Performance monitoring in development + useRenderPerformance('MainLayout'); + + // Memoize auth verification function + const verifyAuthStatus = useCallback(async () => { const session = await dispatch(verifyAuthentication()).unwrap(); if (!session.user.setup_completed) { navigate('/worklenz/setup'); } - }; + }, [dispatch, navigate]); useEffect(() => { void verifyAuthStatus(); - }, [dispatch, navigate]); + }, [verifyAuthStatus]); - const headerStyles = { + // Memoize styles to prevent object recreation on every render + const headerStyles = useMemo(() => ({ zIndex: 999, - position: 'fixed', + position: 'fixed' as const, width: '100%', display: 'flex', alignItems: 'center', padding: 0, borderBottom: themeMode === 'dark' ? '1px solid #303030' : 'none', - } as const; + }), [themeMode]); - const contentStyles = { + const contentStyles = useMemo(() => ({ paddingInline: isDesktop ? 64 : 24, - overflowX: 'hidden', - } as const; + overflowX: 'hidden' as const, + }), [isDesktop]); + + // Memoize theme configuration + const themeConfig = useMemo(() => ({ + components: { + Layout: { + colorBgLayout: themeMode === 'dark' ? colors.darkGray : colors.white, + headerBg: themeMode === 'dark' ? colors.darkGray : colors.white, + }, + }, + }), [themeMode]); + + // Memoize header className + const headerClassName = useMemo(() => + `shadow-md ${themeMode === 'dark' ? '' : 'shadow-[#18181811]'}`, + [themeMode] + ); return ( - + @@ -71,6 +85,8 @@ const MainLayout = () => { ); -}; +}); + +MainLayout.displayName = 'MainLayout'; export default MainLayout; diff --git a/worklenz-frontend/src/pages/TaskManagementDemo.tsx b/worklenz-frontend/src/pages/TaskManagementDemo.tsx index 8204f525..836b4a3f 100644 --- a/worklenz-frontend/src/pages/TaskManagementDemo.tsx +++ b/worklenz-frontend/src/pages/TaskManagementDemo.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; import { Layout, Typography, Card, Space, Alert } from 'antd'; import { useDispatch } from 'react-redux'; -import TaskListBoard from '@/components/task-management/TaskListBoard'; +import TaskListBoard from '@/components/task-management/task-list-board'; import { AppDispatch } from '@/app/store'; const { Header, Content } = Layout; diff --git a/worklenz-frontend/src/pages/home/home-page.tsx b/worklenz-frontend/src/pages/home/home-page.tsx index 820dadc8..f132f170 100644 --- a/worklenz-frontend/src/pages/home/home-page.tsx +++ b/worklenz-frontend/src/pages/home/home-page.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, memo, useMemo, useCallback } from 'react'; import { useMediaQuery } from 'react-responsive'; import Col from 'antd/es/col'; import Flex from 'antd/es/flex'; @@ -19,48 +19,72 @@ import { fetchProjectCategories } from '@/features/projects/lookups/projectCateg import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice'; import { fetchProjects } from '@/features/home-page/home-page.slice'; import { createPortal } from 'react-dom'; -import React from 'react'; +import React, { Suspense } from 'react'; const DESKTOP_MIN_WIDTH = 1024; const TASK_LIST_MIN_WIDTH = 500; const SIDEBAR_MAX_WIDTH = 400; + +// Lazy load heavy components const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer')); -const HomePage = () => { + +const HomePage = memo(() => { const dispatch = useAppDispatch(); const isDesktop = useMediaQuery({ query: `(min-width: ${DESKTOP_MIN_WIDTH}px)` }); const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); + useDocumentTitle('Home'); - useEffect(() => { - const fetchLookups = async () => { - const fetchPromises = [ - dispatch(fetchProjectHealth()), - dispatch(fetchProjectCategories()), - dispatch(fetchProjectStatuses()), - dispatch(fetchProjects()), - ].filter(Boolean); + // Memoize fetch function to prevent recreation on every render + const fetchLookups = useCallback(async () => { + const fetchPromises = [ + dispatch(fetchProjectHealth()), + dispatch(fetchProjectCategories()), + dispatch(fetchProjectStatuses()), + dispatch(fetchProjects()), + ].filter(Boolean); - await Promise.all(fetchPromises); - }; - fetchLookups(); + await Promise.all(fetchPromises); }, [dispatch]); - const CreateProjectButtonComponent = () => - isDesktop ? ( + useEffect(() => { + fetchLookups(); + }, [fetchLookups]); + + // Memoize project drawer close handler + const handleProjectDrawerClose = useCallback(() => {}, []); + + // Memoize desktop flex styles to prevent object recreation + const desktopFlexStyle = useMemo(() => ({ + minWidth: TASK_LIST_MIN_WIDTH, + width: '100%' + }), []); + + const sidebarFlexStyle = useMemo(() => ({ + width: '100%', + maxWidth: SIDEBAR_MAX_WIDTH + }), []); + + // Memoize components to prevent unnecessary re-renders + const CreateProjectButtonComponent = useMemo(() => { + if (!isOwnerOrAdmin) return null; + + return isDesktop ? (
- {isOwnerOrAdmin && } +
) : ( - isOwnerOrAdmin && + ); + }, [isDesktop, isOwnerOrAdmin]); - const MainContent = () => - isDesktop ? ( + const MainContent = useMemo(() => { + return isDesktop ? ( - + - + @@ -72,19 +96,31 @@ const HomePage = () => { ); + }, [isDesktop, desktopFlexStyle, sidebarFlexStyle]); return (
- + {CreateProjectButtonComponent} - - {createPortal(, document.body, 'home-task-drawer')} - {createPortal( {}} />, document.body, 'project-drawer')} + {MainContent} + + {/* Use Suspense for lazy-loaded components */} + + {createPortal(, document.body, 'home-task-drawer')} + + + {createPortal( + , + document.body, + 'project-drawer' + )}
); -}; +}); + +HomePage.displayName = 'HomePage'; export default HomePage; diff --git a/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx b/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx index c6829889..4fbfec50 100644 --- a/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; -import TaskListBoard from '@/components/task-management/TaskListBoard'; +import TaskListBoard from '@/components/task-management/task-list-board'; const ProjectViewEnhancedTasks: React.FC = () => { const { project } = useAppSelector(state => state.projectReducer); diff --git a/worklenz-frontend/src/utils/performance.ts b/worklenz-frontend/src/utils/performance.ts new file mode 100644 index 00000000..f339b543 --- /dev/null +++ b/worklenz-frontend/src/utils/performance.ts @@ -0,0 +1,182 @@ +import React from 'react'; + +/** + * Performance monitoring utilities for development + */ + +const isProduction = import.meta.env.PROD; +const isDevelopment = !isProduction; + +interface PerformanceEntry { + name: string; + startTime: number; + duration?: number; +} + +class PerformanceMonitor { + private timers: Map = new Map(); + private entries: PerformanceEntry[] = []; + + /** + * Start timing a performance measurement + */ + public startTimer(name: string): void { + if (isProduction) return; + + this.timers.set(name, performance.now()); + } + + /** + * End timing and log the result + */ + public endTimer(name: string): number | null { + if (isProduction) return null; + + const startTime = this.timers.get(name); + if (!startTime) { + console.warn(`Performance timer "${name}" was not started`); + return null; + } + + const endTime = performance.now(); + const duration = endTime - startTime; + + this.timers.delete(name); + this.entries.push({ name, startTime, duration }); + + if (isDevelopment) { + const color = duration > 100 ? '#ff4d4f' : duration > 50 ? '#faad14' : '#52c41a'; + console.log( + `%cā±ļø ${name}: ${duration.toFixed(2)}ms`, + `color: ${color}; font-weight: bold;` + ); + } + + return duration; + } + + /** + * Measure the performance of a function + */ + public measure(name: string, fn: () => T): T { + if (isProduction) return fn(); + + this.startTimer(name); + const result = fn(); + this.endTimer(name); + return result; + } + + /** + * Measure the performance of an async function + */ + public async measureAsync(name: string, fn: () => Promise): Promise { + if (isProduction) return fn(); + + this.startTimer(name); + const result = await fn(); + this.endTimer(name); + return result; + } + + /** + * Get all performance entries + */ + public getEntries(): PerformanceEntry[] { + return [...this.entries]; + } + + /** + * Clear all entries + */ + public clearEntries(): void { + this.entries = []; + } + + /** + * Log a summary of all performance entries + */ + public logSummary(): void { + if (isProduction || this.entries.length === 0) return; + + console.group('šŸ“Š Performance Summary'); + + const sortedEntries = this.entries + .filter(entry => entry.duration !== undefined) + .sort((a, b) => (b.duration || 0) - (a.duration || 0)); + + console.table( + sortedEntries.map(entry => ({ + Name: entry.name, + Duration: `${(entry.duration || 0).toFixed(2)}ms`, + 'Start Time': `${entry.startTime.toFixed(2)}ms` + })) + ); + + const totalTime = sortedEntries.reduce((sum, entry) => sum + (entry.duration || 0), 0); + console.log(`%cTotal measured time: ${totalTime.toFixed(2)}ms`, 'font-weight: bold;'); + + console.groupEnd(); + } +} + +// Create default instance +export const performanceMonitor = new PerformanceMonitor(); + +/** + * Higher-order component to measure component render performance + */ +export function withPerformanceMonitoring

( + Component: React.ComponentType

, + componentName?: string +): React.ComponentType

{ + if (isProduction) return Component; + + const name = componentName || Component.displayName || Component.name || 'Unknown'; + + const WrappedComponent = (props: P) => { + React.useEffect(() => { + performanceMonitor.startTimer(`${name} mount`); + return () => { + performanceMonitor.endTimer(`${name} mount`); + }; + }, []); + + React.useEffect(() => { + performanceMonitor.endTimer(`${name} render`); + }); + + performanceMonitor.startTimer(`${name} render`); + return React.createElement(Component, props); + }; + + WrappedComponent.displayName = `withPerformanceMonitoring(${name})`; + return WrappedComponent; +} + +/** + * Hook to measure render performance + */ +export function useRenderPerformance(componentName: string): void { + if (isProduction) return; + + const renderCount = React.useRef(0); + const startTime = React.useRef(0); + + React.useEffect(() => { + renderCount.current += 1; + const endTime = performance.now(); + const duration = endTime - startTime.current; + + if (renderCount.current > 1) { + console.log( + `%cšŸ”„ ${componentName} render #${renderCount.current}: ${duration.toFixed(2)}ms`, + 'color: #1890ff; font-size: 11px;' + ); + } + }); + + startTime.current = performance.now(); +} + +export default performanceMonitor; \ No newline at end of file diff --git a/worklenz-frontend/src/utils/routePreloader.ts b/worklenz-frontend/src/utils/routePreloader.ts new file mode 100644 index 00000000..8df8a3b4 --- /dev/null +++ b/worklenz-frontend/src/utils/routePreloader.ts @@ -0,0 +1,181 @@ +import React from 'react'; + +/** + * Route preloader utility to prefetch components and improve navigation performance + */ + +interface PreloadableRoute { + path: string; + loader: () => Promise; + priority: 'high' | 'medium' | 'low'; +} + +class RoutePreloader { + private preloadedRoutes = new Set(); + private preloadQueue: PreloadableRoute[] = []; + private isPreloading = false; + + /** + * Register a route for preloading + */ + public registerRoute(path: string, loader: () => Promise, priority: 'high' | 'medium' | 'low' = 'medium'): void { + if (this.preloadedRoutes.has(path)) return; + + this.preloadQueue.push({ path, loader, priority }); + this.sortQueue(); + } + + /** + * Preload a specific route immediately + */ + public async preloadRoute(path: string, loader: () => Promise): Promise { + if (this.preloadedRoutes.has(path)) return; + + try { + await loader(); + this.preloadedRoutes.add(path); + } catch (error) { + console.warn(`Failed to preload route: ${path}`, error); + } + } + + /** + * Start preloading routes in the queue + */ + public async startPreloading(): Promise { + if (this.isPreloading || this.preloadQueue.length === 0) return; + + this.isPreloading = true; + + // Use requestIdleCallback if available, otherwise setTimeout + const scheduleWork = (callback: () => void) => { + if ('requestIdleCallback' in window) { + requestIdleCallback(callback, { timeout: 1000 }); + } else { + setTimeout(callback, 0); + } + }; + + const processQueue = async () => { + while (this.preloadQueue.length > 0) { + const route = this.preloadQueue.shift(); + if (!route) break; + + if (this.preloadedRoutes.has(route.path)) continue; + + try { + await route.loader(); + this.preloadedRoutes.add(route.path); + } catch (error) { + console.warn(`Failed to preload route: ${route.path}`, error); + } + + // Yield control back to the browser + await new Promise(resolve => scheduleWork(() => resolve())); + } + + this.isPreloading = false; + }; + + scheduleWork(processQueue); + } + + /** + * Preload routes on user interaction (hover, focus) + */ + public preloadOnInteraction(element: HTMLElement, path: string, loader: () => Promise): void { + if (this.preloadedRoutes.has(path)) return; + + let preloadTriggered = false; + + const handleInteraction = () => { + if (preloadTriggered) return; + preloadTriggered = true; + + this.preloadRoute(path, loader); + + // Clean up listeners + element.removeEventListener('mouseenter', handleInteraction); + element.removeEventListener('focus', handleInteraction); + element.removeEventListener('touchstart', handleInteraction); + }; + + element.addEventListener('mouseenter', handleInteraction, { passive: true }); + element.addEventListener('focus', handleInteraction, { passive: true }); + element.addEventListener('touchstart', handleInteraction, { passive: true }); + } + + /** + * Preload routes when the browser is idle + */ + public preloadOnIdle(): void { + if ('requestIdleCallback' in window) { + requestIdleCallback(() => { + this.startPreloading(); + }, { timeout: 2000 }); + } else { + setTimeout(() => { + this.startPreloading(); + }, 1000); + } + } + + /** + * Check if a route is already preloaded + */ + public isRoutePreloaded(path: string): boolean { + return this.preloadedRoutes.has(path); + } + + /** + * Clear all preloaded routes + */ + public clearPreloaded(): void { + this.preloadedRoutes.clear(); + this.preloadQueue = []; + } + + private sortQueue(): void { + const priorityOrder = { high: 0, medium: 1, low: 2 }; + this.preloadQueue.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]); + } +} + +// Create default instance +export const routePreloader = new RoutePreloader(); + +/** + * React hook to preload routes on component mount + */ +export function useRoutePreloader(routes: Array<{ path: string; loader: () => Promise; priority?: 'high' | 'medium' | 'low' }>): void { + React.useEffect(() => { + routes.forEach(route => { + routePreloader.registerRoute(route.path, route.loader, route.priority); + }); + + // Start preloading after a short delay to not interfere with initial render + const timer = setTimeout(() => { + routePreloader.preloadOnIdle(); + }, 100); + + return () => clearTimeout(timer); + }, [routes]); +} + +/** + * React hook to preload a route on element interaction + */ +export function usePreloadOnHover(path: string, loader: () => Promise) { + const elementRef = React.useRef(null); + + React.useEffect(() => { + const element = elementRef.current; + if (!element) return; + + routePreloader.preloadOnInteraction(element, path, loader); + }, [path, loader]); + + return elementRef; +} + +export default routePreloader; \ No newline at end of file