Merge pull request #169 from Worklenz/imp/task-list-performance-fixes
Imp/task list performance fixes
This commit is contained in:
@@ -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 (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ThemeWrapper>
|
||||
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
||||
<RouterProvider
|
||||
router={router}
|
||||
future={{
|
||||
v7_startTransition: true
|
||||
}}
|
||||
/>
|
||||
</ThemeWrapper>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
App.displayName = 'App';
|
||||
|
||||
export default App;
|
||||
|
||||
122
worklenz-frontend/src/app/performance-monitor.ts
Normal file
122
worklenz-frontend/src/app/performance-monitor.ts
Normal file
@@ -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<string, number>,
|
||||
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;
|
||||
}
|
||||
@@ -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: (
|
||||
<AuthLayout />
|
||||
),
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -22,27 +22,51 @@ const authRoutes = [
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
element: <LoginPage />,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<LoginPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'signup',
|
||||
element: <SignupPage />,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<SignupPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'forgot-password',
|
||||
element: <ForgotPasswordPage />,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ForgotPasswordPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'logging-out',
|
||||
element: <LoggingOutPage />,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<LoggingOutPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'authenticating',
|
||||
element: <AuthenticatingPage />,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<AuthenticatingPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'verify-reset-email/:user/:hash',
|
||||
element: <VerifyResetEmailPage />,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<VerifyResetEmailPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 <Navigate to="/auth" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
// Route-based code splitting utility
|
||||
const withCodeSplitting = (Component: React.LazyExoticComponent<React.ComponentType<any>>) => {
|
||||
return memo(() => (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
));
|
||||
};
|
||||
|
||||
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 <Navigate to="/auth" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (!isOwnerOrAdmin || isFreePlan) {
|
||||
return <Navigate to="/worklenz/unauthorized" replace />;
|
||||
}
|
||||
|
||||
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 <Navigate to="/auth" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
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 <Navigate to={guardResult.redirect} state={guardResult.state} replace />;
|
||||
}
|
||||
|
||||
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 <Navigate to="/worklenz/license-expired" replace />;
|
||||
}
|
||||
|
||||
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 <Navigate to="/auth" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
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: <Guard>{route.element}</Guard>,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<Guard>{route.element}</Guard>
|
||||
</Suspense>
|
||||
),
|
||||
};
|
||||
|
||||
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 (
|
||||
<div style={{
|
||||
marginTop: 65,
|
||||
@@ -184,23 +267,31 @@ const StaticLicenseExpired = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
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: <LicenseExpiryGuard>{route.element}</LicenseExpiryGuard>,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<LicenseExpiryGuard>{route.element}</LicenseExpiryGuard>
|
||||
</Suspense>
|
||||
),
|
||||
};
|
||||
|
||||
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: <ErrorBoundary><AuthenticatedLayout /></ErrorBoundary>,
|
||||
errorElement: <ErrorBoundary><NotFoundPage /></ErrorBoundary>,
|
||||
element: (
|
||||
<ErrorBoundary>
|
||||
<AuthenticatedLayout />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
errorElement: (
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<NotFoundPage />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
),
|
||||
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;
|
||||
|
||||
@@ -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 <Navigate to="/auth" state={{ from: location }} replace />;
|
||||
}
|
||||
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 <Navigate to="/worklenz/unauthorized" replace />;
|
||||
}
|
||||
if (!authService.isAuthenticated()) {
|
||||
return <Navigate to="/auth" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
if (!authService.isOwnerOrAdmin()) {
|
||||
return <Navigate to="/worklenz/unauthorized" replace />;
|
||||
}
|
||||
|
||||
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: <MainLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="home" replace /> },
|
||||
{ path: 'home', element: <HomePage /> },
|
||||
{ path: 'projects', element: <ProjectList /> },
|
||||
{
|
||||
path: 'home',
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<HomePage />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ProjectList />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'schedule',
|
||||
element: <AdminGuard><Schedule /></AdminGuard>
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<AdminGuard>
|
||||
<Schedule />
|
||||
</AdminGuard>
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: `projects/:projectId`,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ProjectView />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{ path: `projects/:projectId`, element: <ProjectView /> },
|
||||
{
|
||||
path: `settings/project-templates/edit/:templateId/:templateName`,
|
||||
element: <ProjectTemplateEditView />,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ProjectTemplateEditView />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'unauthorized',
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<Unauthorized />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{ path: 'unauthorized', element: <Unauthorized /> },
|
||||
...settingsRoutes,
|
||||
...adminCenterRoutes,
|
||||
],
|
||||
@@ -58,7 +113,14 @@ export const licenseExpiredRoute: RouteObject = {
|
||||
path: '/worklenz',
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{ path: 'license-expired', element: <LicenseExpired /> }
|
||||
{
|
||||
path: 'license-expired',
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<LicenseExpired />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
81
worklenz-frontend/src/app/selectors.ts
Normal file
81
worklenz-frontend/src/app/selectors.ts
Normal file
@@ -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;
|
||||
}
|
||||
);
|
||||
@@ -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 (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
components: {
|
||||
Layout: {
|
||||
colorBgLayout: isDark ? colors.darkGray : '#fafafa',
|
||||
},
|
||||
Spin: {
|
||||
colorPrimary: isDark ? '#fff' : '#1890ff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Layout
|
||||
className="app-loading-container"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: 'transparent',
|
||||
transition: 'none',
|
||||
}}
|
||||
>
|
||||
<Spin
|
||||
size="large"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
/>
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
<Layout className="app-loading-container" style={layoutStyle}>
|
||||
<Spin size="large" style={spinStyle} />
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
SuspenseFallback.displayName = 'SuspenseFallback';
|
||||
|
||||
@@ -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<BulkActionBarProps> = ({
|
||||
selectedTaskIds,
|
||||
totalSelected,
|
||||
currentGrouping,
|
||||
projectId,
|
||||
onClearSelection,
|
||||
}) => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
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 = (
|
||||
<Menu
|
||||
onClick={({ key }) => handleBulkStatusChange(key)}
|
||||
items={statuses.map(status => ({
|
||||
key: status.id!,
|
||||
label: (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: status.color_code }}
|
||||
/>
|
||||
<span>{status.name}</span>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
const priorityMenu = (
|
||||
<Menu
|
||||
onClick={({ key }) => handleBulkPriorityChange(key)}
|
||||
items={[
|
||||
{ key: 'critical', label: 'Critical', icon: <div className="w-2 h-2 rounded-full bg-red-500" /> },
|
||||
{ key: 'high', label: 'High', icon: <div className="w-2 h-2 rounded-full bg-orange-500" /> },
|
||||
{ key: 'medium', label: 'Medium', icon: <div className="w-2 h-2 rounded-full bg-yellow-500" /> },
|
||||
{ key: 'low', label: 'Low', icon: <div className="w-2 h-2 rounded-full bg-green-500" /> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
const moreActionsMenu = (
|
||||
<Menu
|
||||
items={[
|
||||
{
|
||||
key: 'assign',
|
||||
label: 'Assign to member',
|
||||
icon: <UserOutlined />,
|
||||
},
|
||||
{
|
||||
key: 'labels',
|
||||
label: 'Add labels',
|
||||
icon: <TagOutlined />,
|
||||
},
|
||||
{
|
||||
key: 'archive',
|
||||
label: 'Archive tasks',
|
||||
icon: <EditOutlined />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="mb-4 bg-blue-50 border-blue-200"
|
||||
styles={{ body: { padding: '8px 16px' } }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Text strong className="text-blue-700">
|
||||
{totalSelected} task{totalSelected > 1 ? 's' : ''} selected
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
{/* Status Change */}
|
||||
{currentGrouping !== 'status' && (
|
||||
<Dropdown overlay={statusMenu} trigger={['click']}>
|
||||
<Button size="small" icon={<CheckOutlined />}>
|
||||
Change Status
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
{/* Priority Change */}
|
||||
{currentGrouping !== 'priority' && (
|
||||
<Dropdown overlay={priorityMenu} trigger={['click']}>
|
||||
<Button size="small" icon={<EditOutlined />}>
|
||||
Set Priority
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
{/* More Actions */}
|
||||
<Dropdown overlay={moreActionsMenu} trigger={['click']}>
|
||||
<Button size="small" icon={<MoreOutlined />}>
|
||||
More Actions
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
{/* Delete */}
|
||||
<Popconfirm
|
||||
title={`Delete ${totalSelected} task${totalSelected > 1 ? 's' : ''}?`}
|
||||
description="This action cannot be undone."
|
||||
onConfirm={handleBulkDelete}
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
okType="danger"
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
Delete
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
|
||||
{/* Clear Selection */}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClearSelection}
|
||||
title="Clear selection"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkActionBar;
|
||||
@@ -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<BulkActionBarProps> = ({
|
||||
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<InputRef>(null);
|
||||
const [createLabelText, setCreateLabelText] = useState<string>('');
|
||||
const [teamMembersSorted, setTeamMembersSorted] = useState<ITeamMembersViewModel>({
|
||||
data: [],
|
||||
total: 0,
|
||||
});
|
||||
const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false);
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
const [selectedLabels, setSelectedLabels] = useState<ITaskLabel[]>([]);
|
||||
|
||||
// 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: <Badge color={status.color_code} text={status.name} />,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('priority'),
|
||||
children: priorityList.map(priority => ({
|
||||
key: priority.id,
|
||||
onClick: () => handleChangePriority(priority),
|
||||
label: <Badge color={priority.color_code} text={priority.name} />,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: t('phase'),
|
||||
children: phaseList.map(phase => ({
|
||||
key: phase.id,
|
||||
onClick: () => handleChangePhase(phase),
|
||||
label: <Badge color={phase.color_code} text={phase.name} />,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (members?.data && assigneeDropdownOpen) {
|
||||
let sortedMembers = sortTeamMembers(members.data);
|
||||
setTeamMembersSorted({ data: sortedMembers, total: members.total });
|
||||
}
|
||||
}, [assigneeDropdownOpen, members?.data]);
|
||||
|
||||
const getAssigneesMenu = () => {
|
||||
return (
|
||||
<AssigneesDropdown
|
||||
members={teamMembersSorted?.data || []}
|
||||
themeMode={themeMode}
|
||||
onApply={handleChangeAssignees}
|
||||
onClose={() => 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 = (
|
||||
<LabelsDropdown
|
||||
labelsList={labelsList}
|
||||
themeMode={themeMode}
|
||||
createLabelText={createLabelText}
|
||||
selectedLabels={selectedLabels}
|
||||
labelsInputRef={labelsInputRef as React.RefObject<InputRef>}
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '30px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#252628',
|
||||
borderRadius: '25px',
|
||||
padding: '8px 16px',
|
||||
boxShadow: '0 0 0 1px #434343, 0 4px 12px 0 rgba(0, 0, 0, 0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
minWidth: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontSize: '14px', fontWeight: 500, marginRight: '8px' }}>
|
||||
{totalSelected} task{totalSelected > 1 ? 's' : ''} selected
|
||||
</Text>
|
||||
|
||||
{/* Status/Priority/Phase Change */}
|
||||
<Tooltip title="Change Status/Priority/Phase">
|
||||
<Dropdown menu={{ items: getChangeOptionsMenu() }} trigger={['click']}>
|
||||
<Button
|
||||
icon={<RetweetOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
loading={loading}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
|
||||
{/* Labels */}
|
||||
<Tooltip title="Add Labels">
|
||||
<Dropdown
|
||||
dropdownRender={() => labelsDropdownContent}
|
||||
placement="top"
|
||||
arrow
|
||||
trigger={['click']}
|
||||
onOpenChange={value => {
|
||||
if (!value) {
|
||||
setSelectedLabels([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<TagsOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
loading={updatingLabels}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
|
||||
{/* Assign to Me */}
|
||||
<Tooltip title="Assign to Me">
|
||||
<Button
|
||||
icon={<UserAddOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
onClick={handleAssignToMe}
|
||||
loading={updatingAssignToMe}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Assign Members */}
|
||||
<Tooltip title="Assign Members">
|
||||
<Dropdown
|
||||
dropdownRender={getAssigneesMenu}
|
||||
open={assigneeDropdownOpen}
|
||||
onOpenChange={onAssigneeDropdownOpenChange}
|
||||
placement="top"
|
||||
arrow
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
icon={<UsergroupAddOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
loading={updatingAssignees}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
|
||||
{/* Archive */}
|
||||
<Tooltip title={archived ? 'Unarchive' : 'Archive'}>
|
||||
<Button
|
||||
icon={<InboxOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
onClick={handleArchive}
|
||||
loading={updatingArchive}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Delete */}
|
||||
<Tooltip title="Delete">
|
||||
<Popconfirm
|
||||
title={`Delete ${totalSelected} task${totalSelected > 1 ? 's' : ''}?`}
|
||||
description="This action cannot be undone."
|
||||
onConfirm={handleDelete}
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
okType="danger"
|
||||
>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
loading={updatingDelete}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
|
||||
{/* More Actions - Only for Owner/Admin */}
|
||||
{isOwnerOrAdmin && (
|
||||
<Tooltip title="More Actions">
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'createTemplate',
|
||||
label: 'Create task template',
|
||||
onClick: () => setShowDrawer(true),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<MoreOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
/>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Clear Selection */}
|
||||
<Tooltip title="Clear Selection">
|
||||
<Button
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClearSelection}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Task Template Drawer */}
|
||||
{createPortal(
|
||||
<TaskTemplateDrawer
|
||||
showDrawer={showDrawer}
|
||||
selectedTemplateId={null}
|
||||
onClose={() => {
|
||||
setShowDrawer(false);
|
||||
dispatch(deselectAll());
|
||||
onClearSelection?.();
|
||||
}}
|
||||
/>,
|
||||
document.body,
|
||||
'create-task-template'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BulkActionBar: React.FC<BulkActionBarProps> = (props) => {
|
||||
// Render the bulk action bar through a portal to avoid suspense issues
|
||||
return createPortal(
|
||||
<BulkActionBarContent {...props} />,
|
||||
document.body,
|
||||
'bulk-action-bar'
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkActionBar;
|
||||
@@ -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<TaskGroupProps> = ({
|
||||
|
||||
{/* Column Headers */}
|
||||
{!isCollapsed && totalTasks > 0 && (
|
||||
<div className="task-group-column-headers">
|
||||
<div
|
||||
className="task-group-column-headers"
|
||||
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
|
||||
>
|
||||
<div className="task-group-column-headers-row">
|
||||
<div className="task-table-fixed-columns">
|
||||
<div
|
||||
@@ -182,7 +185,10 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||
|
||||
{/* Tasks List */}
|
||||
{!isCollapsed && (
|
||||
<div className="task-group-body">
|
||||
<div
|
||||
className="task-group-body"
|
||||
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
|
||||
>
|
||||
{group.tasks.length === 0 ? (
|
||||
<div className="task-group-empty">
|
||||
<div className="task-table-fixed-columns">
|
||||
@@ -262,7 +268,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||
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;
|
||||
@@ -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<TaskListBoardProps> = ({ projectId, className = ''
|
||||
totalSelected={selectedTaskIds.length}
|
||||
currentGrouping={groupBy}
|
||||
projectId={projectId}
|
||||
onClearSelection={() => setSelectedTaskIds([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div ref={configRef} className={`theme-${themeMode}`}>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div ref={configRef} className={themeClassName}>
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
ThemeWrapper.displayName = 'ThemeWrapper';
|
||||
|
||||
export default ThemeWrapper;
|
||||
|
||||
@@ -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 (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Layout: {
|
||||
colorBgLayout: themeMode === 'dark' ? colors.darkGray : colors.white,
|
||||
headerBg: themeMode === 'dark' ? colors.darkGray : colors.white,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Layout.Header
|
||||
className={`shadow-md ${themeMode === 'dark' ? '' : 'shadow-[#18181811]'}`}
|
||||
className={headerClassName}
|
||||
style={headerStyles}
|
||||
>
|
||||
<Navbar />
|
||||
@@ -71,6 +85,8 @@ const MainLayout = () => {
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
MainLayout.displayName = 'MainLayout';
|
||||
|
||||
export default MainLayout;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
||||
{isOwnerOrAdmin && <CreateProjectButton />}
|
||||
<CreateProjectButton />
|
||||
</div>
|
||||
) : (
|
||||
isOwnerOrAdmin && <CreateProjectButton />
|
||||
<CreateProjectButton />
|
||||
);
|
||||
}, [isDesktop, isOwnerOrAdmin]);
|
||||
|
||||
const MainContent = () =>
|
||||
isDesktop ? (
|
||||
const MainContent = useMemo(() => {
|
||||
return isDesktop ? (
|
||||
<Flex gap={24} align="flex-start" className="w-full mt-12">
|
||||
<Flex style={{ minWidth: TASK_LIST_MIN_WIDTH, width: '100%' }}>
|
||||
<Flex style={desktopFlexStyle}>
|
||||
<TasksList />
|
||||
</Flex>
|
||||
<Flex vertical gap={24} style={{ width: '100%', maxWidth: SIDEBAR_MAX_WIDTH }}>
|
||||
<Flex vertical gap={24} style={sidebarFlexStyle}>
|
||||
<TodoList />
|
||||
<RecentAndFavouriteProjectList />
|
||||
</Flex>
|
||||
@@ -72,19 +96,31 @@ const HomePage = () => {
|
||||
<RecentAndFavouriteProjectList />
|
||||
</Flex>
|
||||
);
|
||||
}, [isDesktop, desktopFlexStyle, sidebarFlexStyle]);
|
||||
|
||||
return (
|
||||
<div className="my-24 min-h-[90vh]">
|
||||
<Col className="flex flex-col gap-6">
|
||||
<GreetingWithTime />
|
||||
<CreateProjectButtonComponent />
|
||||
{CreateProjectButtonComponent}
|
||||
</Col>
|
||||
|
||||
<MainContent />
|
||||
{createPortal(<TaskDrawer />, document.body, 'home-task-drawer')}
|
||||
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
|
||||
{MainContent}
|
||||
|
||||
{/* Use Suspense for lazy-loaded components */}
|
||||
<Suspense fallback={null}>
|
||||
{createPortal(<TaskDrawer />, document.body, 'home-task-drawer')}
|
||||
</Suspense>
|
||||
|
||||
{createPortal(
|
||||
<ProjectDrawer onClose={handleProjectDrawerClose} />,
|
||||
document.body,
|
||||
'project-drawer'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
HomePage.displayName = 'HomePage';
|
||||
|
||||
export default HomePage;
|
||||
|
||||
@@ -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);
|
||||
|
||||
182
worklenz-frontend/src/utils/performance.ts
Normal file
182
worklenz-frontend/src/utils/performance.ts
Normal file
@@ -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<string, number> = 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<T>(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<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
||||
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<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
componentName?: string
|
||||
): React.ComponentType<P> {
|
||||
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<number>(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;
|
||||
181
worklenz-frontend/src/utils/routePreloader.ts
Normal file
181
worklenz-frontend/src/utils/routePreloader.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Route preloader utility to prefetch components and improve navigation performance
|
||||
*/
|
||||
|
||||
interface PreloadableRoute {
|
||||
path: string;
|
||||
loader: () => Promise<any>;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
class RoutePreloader {
|
||||
private preloadedRoutes = new Set<string>();
|
||||
private preloadQueue: PreloadableRoute[] = [];
|
||||
private isPreloading = false;
|
||||
|
||||
/**
|
||||
* Register a route for preloading
|
||||
*/
|
||||
public registerRoute(path: string, loader: () => Promise<any>, 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<any>): Promise<void> {
|
||||
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<void> {
|
||||
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<void>(resolve => scheduleWork(() => resolve()));
|
||||
}
|
||||
|
||||
this.isPreloading = false;
|
||||
};
|
||||
|
||||
scheduleWork(processQueue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload routes on user interaction (hover, focus)
|
||||
*/
|
||||
public preloadOnInteraction(element: HTMLElement, path: string, loader: () => Promise<any>): 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<any>; 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<any>) {
|
||||
const elementRef = React.useRef<HTMLElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
routePreloader.preloadOnInteraction(element, path, loader);
|
||||
}, [path, loader]);
|
||||
|
||||
return elementRef;
|
||||
}
|
||||
|
||||
export default routePreloader;
|
||||
Reference in New Issue
Block a user