Merge branch 'fix/performance-improvements' of https://github.com/Worklenz/worklenz into feature/project-finance

This commit is contained in:
chamikaJ
2025-05-30 11:42:48 +05:30
20 changed files with 2227 additions and 2092 deletions

View File

@@ -1,131 +0,0 @@
module.exports = function (grunt) {
// Project configuration.
grunt.initConfig({
pkg: grunt.file.readJSON("package.json"),
clean: {
dist: "build"
},
compress: require("./grunt/grunt-compress"),
copy: {
main: {
files: [
{expand: true, cwd: "src", src: ["public/**"], dest: "build"},
{expand: true, cwd: "src", src: ["views/**"], dest: "build"},
{expand: true, cwd: "landing-page-assets", src: ["**"], dest: "build/public/assets"},
{expand: true, cwd: "src", src: ["shared/sample-data.json"], dest: "build", filter: "isFile"},
{expand: true, cwd: "src", src: ["shared/templates/**"], dest: "build", filter: "isFile"},
{expand: true, cwd: "src", src: ["shared/postgresql-error-codes.json"], dest: "build", filter: "isFile"},
]
},
packages: {
files: [
{expand: true, cwd: "", src: [".env"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: [".gitignore"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["release"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["jest.config.js"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["package.json"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["package-lock.json"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["common_modules/**"], dest: "build"}
]
}
},
sync: {
main: {
files: [
{cwd: "src", src: ["views/**", "public/**"], dest: "build/"}, // makes all src relative to cwd
],
verbose: true,
failOnError: true,
compareUsing: "md5"
}
},
uglify: {
all: {
files: [{
expand: true,
cwd: "build",
src: "**/*.js",
dest: "build"
}]
},
controllers: {
files: [{
expand: true,
cwd: "build",
src: "controllers/*.js",
dest: "build"
}]
},
routes: {
files: [{
expand: true,
cwd: "build",
src: "routes/**/*.js",
dest: "build"
}]
},
assets: {
files: [{
expand: true,
cwd: "build",
src: "public/assets/**/*.js",
dest: "build"
}]
}
},
shell: {
tsc: {
command: "tsc --build tsconfig.prod.json"
},
esbuild: {
// command: "esbuild `find src -type f -name '*.ts'` --platform=node --minify=false --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=build"
command: "node esbuild && node cli/esbuild-patch"
},
tsc_dev: {
command: "tsc --build tsconfig.json"
},
swagger: {
command: "node ./cli/swagger"
},
inline_queries: {
command: "node ./cli/inline-queries"
}
},
watch: {
scripts: {
files: ["src/**/*.ts"],
tasks: ["shell:tsc_dev"],
options: {
debounceDelay: 250,
spawn: false,
}
},
other: {
files: ["src/**/*.pug", "landing-page-assets/**"],
tasks: ["sync"]
}
}
});
grunt.registerTask("clean", ["clean"]);
grunt.registerTask("copy", ["copy:main"]);
grunt.registerTask("swagger", ["shell:swagger"]);
grunt.registerTask("build:tsc", ["shell:tsc"]);
grunt.registerTask("build", ["clean", "shell:tsc", "copy:main", "compress"]);
grunt.registerTask("build:es", ["clean", "shell:esbuild", "copy:main", "uglify:assets", "compress"]);
grunt.registerTask("build:strict", ["clean", "shell:tsc", "copy:packages", "uglify:all", "copy:main", "compress"]);
grunt.registerTask("dev", ["clean", "copy:main", "shell:tsc_dev", "shell:inline_queries", "watch"]);
// Load the plugin that provides the "uglify" task.
grunt.loadNpmTasks("grunt-contrib-watch");
grunt.loadNpmTasks("grunt-contrib-clean");
grunt.loadNpmTasks("grunt-contrib-copy");
grunt.loadNpmTasks("grunt-contrib-uglify");
grunt.loadNpmTasks("grunt-contrib-compress");
grunt.loadNpmTasks("grunt-shell");
grunt.loadNpmTasks("grunt-sync");
// Default task(s).
grunt.registerTask("default", []);
};

File diff suppressed because it is too large Load Diff

View File

@@ -11,16 +11,30 @@
"repository": "GITHUB_REPO_HERE",
"author": "worklenz.com",
"scripts": {
"start": "node ./build/bin/www",
"tcs": "grunt build:tsc",
"build": "grunt build",
"watch": "grunt watch",
"dev": "grunt dev",
"es": "esbuild `find src -type f -name '*.ts'` --platform=node --minify=true --watch=true --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=dist",
"copy": "grunt copy",
"test": "jest",
"start": "node build/bin/www.js",
"dev": "npm run build:dev && npm run watch",
"build": "npm run clean && npm run compile && npm run copy && npm run compress",
"build:dev": "npm run clean && npm run compile:dev && npm run copy",
"build:prod": "npm run clean && npm run compile:prod && npm run copy && npm run minify && npm run compress",
"clean": "rimraf build",
"compile": "tsc --build tsconfig.prod.json",
"compile:dev": "tsc --build tsconfig.json",
"compile:prod": "tsc --build tsconfig.prod.json",
"copy": "npm run copy:assets && npm run copy:views && npm run copy:config && npm run copy:shared",
"copy:assets": "npx cpx2 \"src/public/**\" build/public",
"copy:views": "npx cpx2 \"src/views/**\" build/views",
"copy:config": "npx cpx2 \".env\" build && npx cpx2 \"package.json\" build",
"copy:shared": "npx cpx2 \"src/shared/postgresql-error-codes.json\" build/shared && npx cpx2 \"src/shared/sample-data.json\" build/shared && npx cpx2 \"src/shared/templates/**\" build/shared/templates",
"watch": "concurrently \"npm run watch:ts\" \"npm run watch:assets\"",
"watch:ts": "tsc --build tsconfig.json --watch",
"watch:assets": "npx cpx2 \"src/{public,views}/**\" build --watch",
"minify": "terser build/**/*.js --compress --mangle --output-dir build",
"compress": "node scripts/compress.js",
"swagger": "node ./cli/swagger",
"inline-queries": "node ./cli/inline-queries",
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
"tsc": "tsc",
"test": "jest --setupFiles dotenv/config",
"test:watch": "jest --watch --setupFiles dotenv/config"
},
"jestSonar": {
@@ -45,6 +59,7 @@
"cors": "^2.8.5",
"cron": "^2.4.0",
"crypto-js": "^4.1.1",
"csrf-sync": "^4.2.1",
"csurf": "^1.11.0",
"debug": "^4.3.4",
"dotenv": "^16.3.1",
@@ -120,26 +135,22 @@
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"chokidar": "^3.5.3",
"concurrently": "^9.1.2",
"cpx2": "^8.0.0",
"esbuild": "^0.17.19",
"esbuild-envfile-plugin": "^1.0.5",
"esbuild-node-externals": "^1.8.0",
"eslint": "^8.45.0",
"eslint-plugin-security": "^1.7.1",
"fs-extra": "^10.1.0",
"grunt": "^1.6.1",
"grunt-contrib-clean": "^2.0.1",
"grunt-contrib-compress": "^2.0.0",
"grunt-contrib-copy": "^1.0.0",
"grunt-contrib-uglify": "^5.2.2",
"grunt-contrib-watch": "^1.1.0",
"grunt-shell": "^4.0.0",
"grunt-sync": "^0.8.2",
"highcharts": "^11.1.0",
"jest": "^28.1.3",
"jest-sonar-reporter": "^2.0.0",
"ncp": "^2.0.0",
"nodeman": "^1.1.2",
"rimraf": "^6.0.1",
"swagger-jsdoc": "^6.2.8",
"terser": "^5.40.0",
"ts-jest": "^28.0.8",
"ts-node": "^10.9.1",
"tslint": "^6.1.3",

View File

@@ -0,0 +1,53 @@
const fs = require('fs');
const path = require('path');
const { createGzip } = require('zlib');
const { pipeline } = require('stream');
async function compressFile(inputPath, outputPath) {
return new Promise((resolve, reject) => {
const gzip = createGzip();
const source = fs.createReadStream(inputPath);
const destination = fs.createWriteStream(outputPath);
pipeline(source, gzip, destination, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
async function compressDirectory(dir) {
const files = fs.readdirSync(dir, { withFileTypes: true });
for (const file of files) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
await compressDirectory(fullPath);
} else if (file.name.endsWith('.js') || file.name.endsWith('.css')) {
const gzPath = fullPath + '.gz';
await compressFile(fullPath, gzPath);
console.log(`Compressed: ${fullPath} -> ${gzPath}`);
}
}
}
async function main() {
try {
const buildDir = path.join(__dirname, '../build');
if (fs.existsSync(buildDir)) {
await compressDirectory(buildDir);
console.log('Compression complete!');
} else {
console.log('Build directory not found. Run build first.');
}
} catch (error) {
console.error('Compression failed:', error);
process.exit(1);
}
}
main();

View File

@@ -6,7 +6,7 @@ import logger from "morgan";
import helmet from "helmet";
import compression from "compression";
import passport from "passport";
import csurf from "csurf";
import { csrfSync } from "csrf-sync";
import rateLimit from "express-rate-limit";
import cors from "cors";
import flash from "connect-flash";
@@ -112,17 +112,13 @@ function isLoggedIn(req: Request, _res: Response, next: NextFunction) {
return req.user ? next() : next(createError(401));
}
// CSRF configuration
const csrfProtection = csurf({
cookie: {
key: "XSRF-TOKEN",
path: "/",
httpOnly: false,
secure: isProduction(), // Only secure in production
sameSite: isProduction() ? "none" : "lax", // Different settings for dev vs prod
domain: isProduction() ? ".worklenz.com" : undefined // Only set domain in production
},
ignoreMethods: ["HEAD", "OPTIONS"]
// CSRF configuration using csrf-sync for session-based authentication
const {
invalidCsrfTokenError,
generateToken,
csrfSynchronisedProtection,
} = csrfSync({
getTokenFromRequest: (req: Request) => req.headers["x-csrf-token"] as string || (req.body && req.body["_csrf"])
});
// Apply CSRF selectively (exclude webhooks and public routes)
@@ -135,38 +131,25 @@ app.use((req, res, next) => {
) {
next();
} else {
csrfProtection(req, res, next);
csrfSynchronisedProtection(req, res, next);
}
});
// Set CSRF token cookie
// Set CSRF token method on request object for compatibility
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.csrfToken) {
const token = req.csrfToken();
res.cookie("XSRF-TOKEN", token, {
httpOnly: false,
secure: isProduction(),
sameSite: isProduction() ? "none" : "lax",
domain: isProduction() ? ".worklenz.com" : undefined,
path: "/"
});
// Add csrfToken method to request object for compatibility
if (!req.csrfToken && generateToken) {
req.csrfToken = (overwrite?: boolean) => generateToken(req, overwrite);
}
next();
});
// CSRF token refresh endpoint
app.get("/csrf-token", (req: Request, res: Response) => {
if (req.csrfToken) {
const token = req.csrfToken();
res.cookie("XSRF-TOKEN", token, {
httpOnly: false,
secure: isProduction(),
sameSite: isProduction() ? "none" : "lax",
domain: isProduction() ? ".worklenz.com" : undefined,
path: "/"
});
res.status(200).json({ done: true, message: "CSRF token refreshed" });
} else {
try {
const token = generateToken(req);
res.status(200).json({ done: true, message: "CSRF token refreshed", token });
} catch (error) {
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
}
});
@@ -219,7 +202,7 @@ if (isInternalServer()) {
// CSRF error handler
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
if (err.code === "EBADCSRFTOKEN") {
if (err === invalidCsrfTokenError) {
return res.status(403).json({
done: false,
message: "Invalid CSRF token",

View File

@@ -13,6 +13,7 @@ import router from './app/routes';
// Hooks & Utils
import { useAppSelector } from './hooks/useAppSelector';
import { initMixpanel } from './utils/mixpanelInit';
import { initializeCsrfToken } from './api/api-client';
// Types & Constants
import { Language } from './features/i18n/localesSlice';
@@ -35,6 +36,13 @@ const App: React.FC<{ children: React.ReactNode }> = ({ children }) => {
});
}, [language]);
// Initialize CSRF token on app startup
useEffect(() => {
initializeCsrfToken().catch(error => {
logger.error('Failed to initialize CSRF token:', error);
});
}, []);
return (
<Suspense fallback={<SuspenseFallback />}>
<ThemeWrapper>

View File

@@ -4,27 +4,36 @@ import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
import config from '@/config/env';
export const getCsrfToken = (): string | null => {
const match = document.cookie.split('; ').find(cookie => cookie.startsWith('XSRF-TOKEN='));
// Store CSRF token in memory (since csrf-sync uses session-based tokens)
let csrfToken: string | null = null;
if (!match) {
return null;
}
return decodeURIComponent(match.split('=')[1]);
export const getCsrfToken = (): string | null => {
return csrfToken;
};
// Function to refresh CSRF token if needed
// Function to refresh CSRF token from server
export const refreshCsrfToken = async (): Promise<string | null> => {
try {
// Make a GET request to the server to get a fresh CSRF token
await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
return getCsrfToken();
const response = await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
if (response.data && response.data.token) {
csrfToken = response.data.token;
return csrfToken;
}
return null;
} catch (error) {
console.error('Failed to refresh CSRF token:', error);
return null;
}
};
// Initialize CSRF token on app load
export const initializeCsrfToken = async (): Promise<void> => {
if (!csrfToken) {
await refreshCsrfToken();
}
};
const apiClient = axios.create({
baseURL: config.apiUrl,
withCredentials: true,
@@ -36,12 +45,16 @@ const apiClient = axios.create({
// Request interceptor
apiClient.interceptors.request.use(
config => {
const token = getCsrfToken();
if (token) {
config.headers['X-CSRF-Token'] = token;
async config => {
// Ensure we have a CSRF token before making requests
if (!csrfToken) {
await refreshCsrfToken();
}
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken;
} else {
console.warn('No CSRF token found');
console.warn('No CSRF token available');
}
return config;
},
@@ -84,7 +97,7 @@ apiClient.interceptors.response.use(
(typeof errorResponse.data === 'object' &&
errorResponse.data !== null &&
'message' in errorResponse.data &&
errorResponse.data.message === 'Invalid CSRF token' ||
(errorResponse.data.message === 'invalid csrf token' || errorResponse.data.message === 'Invalid CSRF token') ||
(error as any).code === 'EBADCSRFTOKEN')) {
alertService.error('Security Error', 'Invalid security token. Refreshing your session...');
@@ -94,7 +107,7 @@ apiClient.interceptors.response.use(
// Update the token in the failed request
error.config.headers['X-CSRF-Token'] = newToken;
// Retry the original request with the new token
return axios(error.config);
return apiClient(error.config);
} else {
// If token refresh failed, redirect to login
window.location.href = '/auth/login';

View File

@@ -5,7 +5,7 @@ import { toQueryString } from '@/utils/toQueryString';
import { IHomeTasksModel, IHomeTasksConfig } from '@/types/home/home-page.types';
import { IMyTask } from '@/types/home/my-tasks.types';
import { IProject } from '@/types/project/project.types';
import { getCsrfToken } from '../api-client';
import { getCsrfToken, refreshCsrfToken } from '../api-client';
import config from '@/config/env';
const rootUrl = '/home';
@@ -14,9 +14,18 @@ const api = createApi({
reducerPath: 'homePageApi',
baseQuery: fetchBaseQuery({
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
prepareHeaders: headers => {
headers.set('X-CSRF-Token', getCsrfToken() || '');
prepareHeaders: async headers => {
// Get CSRF token, refresh if needed
let token = getCsrfToken();
if (!token) {
token = await refreshCsrfToken();
}
if (token) {
headers.set('X-CSRF-Token', token);
}
headers.set('Content-Type', 'application/json');
return headers;
},
credentials: 'include',
}),

View File

@@ -5,7 +5,7 @@ import { IProjectCategory } from '@/types/project/projectCategory.types';
import { IProjectsViewModel } from '@/types/project/projectsViewModel.types';
import { IServerResponse } from '@/types/common.types';
import { IProjectMembersViewModel } from '@/types/projectMember.types';
import { getCsrfToken } from '../api-client';
import { getCsrfToken, refreshCsrfToken } from '../api-client';
import config from '@/config/env';
const rootUrl = '/projects';
@@ -14,9 +14,18 @@ export const projectsApi = createApi({
reducerPath: 'projectsApi',
baseQuery: fetchBaseQuery({
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
prepareHeaders: headers => {
headers.set('X-CSRF-Token', getCsrfToken() || '');
prepareHeaders: async headers => {
// Get CSRF token, refresh if needed
let token = getCsrfToken();
if (!token) {
token = await refreshCsrfToken();
}
if (token) {
headers.set('X-CSRF-Token', token);
}
headers.set('Content-Type', 'application/json');
return headers;
},
credentials: 'include',
}),

View File

@@ -102,14 +102,6 @@ const Navbar = () => {
justifyContent: 'space-between',
}}
>
{/* {daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && (
<Alert
message={daysUntilExpiry > 0 ? `Your license will expire in ${daysUntilExpiry} days` : `Your license has expired ${Math.abs(daysUntilExpiry)} days ago`}
type="warning"
showIcon
style={{ width: '100%', marginTop: 12 }}
/>
)} */}
<Flex
style={{
width: '100%',

View File

@@ -0,0 +1,69 @@
import { useEffect, useCallback } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import {
fetchLabelsByProject,
fetchTaskAssignees,
} from '@/features/tasks/tasks.slice';
import { getTeamMembers } from '@/features/team-members/team-members.slice';
/**
* Hook to manage filter data loading independently of main task list loading
* This ensures filter data loading doesn't block the main UI skeleton
*/
export const useFilterDataLoader = () => {
const dispatch = useAppDispatch();
const { priorities } = useAppSelector(state => ({
priorities: state.priorityReducer.priorities,
}));
const { projectId } = useAppSelector(state => ({
projectId: state.projectReducer.projectId,
}));
// Load filter data asynchronously
const loadFilterData = useCallback(async () => {
try {
// Load priorities if not already loaded (usually fast/cached)
if (!priorities.length) {
dispatch(fetchPriorities());
}
// Load project-specific data in parallel without blocking
if (projectId) {
// These dispatch calls are fire-and-forget
// They will update the UI when ready, but won't block initial render
dispatch(fetchLabelsByProject(projectId));
dispatch(fetchTaskAssignees(projectId));
}
// Load team members for member filters
dispatch(getTeamMembers({
index: 0,
size: 100,
field: null,
order: null,
search: null,
all: true
}));
} catch (error) {
console.error('Error loading filter data:', error);
// Don't throw - filter loading errors shouldn't break the main UI
}
}, [dispatch, priorities.length, projectId]);
// Load filter data on mount and when dependencies change
useEffect(() => {
// Use setTimeout to ensure this runs after the main component render
// This prevents filter loading from blocking the initial render
const timeoutId = setTimeout(loadFilterData, 0);
return () => clearTimeout(timeoutId);
}, [loadFilterData]);
return {
loadFilterData,
};
};

View File

@@ -0,0 +1,146 @@
import { useMemo, useCallback } from 'react';
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
KeyboardSensor,
TouchSensor,
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { updateTaskStatus } from '@/features/tasks/tasks.slice';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
export const useTaskDragAndDrop = () => {
const dispatch = useAppDispatch();
const { taskGroups, groupBy } = useAppSelector(state => ({
taskGroups: state.taskReducer.taskGroups,
groupBy: state.taskReducer.groupBy,
}));
// Memoize sensors configuration for better performance
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
const handleDragStart = useCallback((event: DragStartEvent) => {
// Add visual feedback for drag start
const { active } = event;
if (active) {
document.body.style.cursor = 'grabbing';
}
}, []);
const handleDragOver = useCallback((event: DragOverEvent) => {
// Handle drag over logic if needed
// This can be used for visual feedback during drag
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
// Reset cursor
document.body.style.cursor = '';
const { active, over } = event;
if (!active || !over || !taskGroups) {
return;
}
try {
const activeId = active.id as string;
const overId = over.id as string;
// Find the task being dragged
let draggedTask: IProjectTask | null = null;
let sourceGroupId: string | null = null;
for (const group of taskGroups) {
const task = group.tasks?.find((t: IProjectTask) => t.id === activeId);
if (task) {
draggedTask = task;
sourceGroupId = group.id;
break;
}
}
if (!draggedTask || !sourceGroupId) {
console.warn('Could not find dragged task');
return;
}
// Determine target group
let targetGroupId: string | null = null;
// Check if dropped on a group container
const targetGroup = taskGroups.find((group: ITaskListGroup) => group.id === overId);
if (targetGroup) {
targetGroupId = targetGroup.id;
} else {
// Check if dropped on another task
for (const group of taskGroups) {
const targetTask = group.tasks?.find((t: IProjectTask) => t.id === overId);
if (targetTask) {
targetGroupId = group.id;
break;
}
}
}
if (!targetGroupId || targetGroupId === sourceGroupId) {
return; // No change needed
}
// Update task status based on group change
const targetGroupData = taskGroups.find((group: ITaskListGroup) => group.id === targetGroupId);
if (targetGroupData && groupBy === 'status') {
const updatePayload: any = {
task_id: draggedTask.id,
status_id: targetGroupData.id,
};
if (draggedTask.parent_task_id) {
updatePayload.parent_task = draggedTask.parent_task_id;
}
dispatch(updateTaskStatus(updatePayload));
}
} catch (error) {
console.error('Error handling drag end:', error);
}
},
[taskGroups, groupBy, dispatch]
);
// Memoize the drag and drop configuration
const dragAndDropConfig = useMemo(
() => ({
sensors,
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragEnd: handleDragEnd,
}),
[sensors, handleDragStart, handleDragOver, handleDragEnd]
);
return dragAndDropConfig;
};

View File

@@ -0,0 +1,343 @@
import { useCallback, useEffect } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { SocketEvents } from '@/shared/socket-events';
import logger from '@/utils/errorLogger';
import alertService from '@/services/alerts/alertService';
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
import { ILabelsChangeResponse } from '@/types/tasks/taskList.types';
import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import {
fetchTaskAssignees,
updateTaskAssignees,
fetchLabelsByProject,
updateTaskLabel,
updateTaskStatus,
updateTaskPriority,
updateTaskEndDate,
updateTaskEstimation,
updateTaskName,
updateTaskPhase,
updateTaskStartDate,
updateTaskDescription,
updateSubTasks,
updateTaskProgress,
} from '@/features/tasks/tasks.slice';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import {
setStartDate,
setTaskAssignee,
setTaskEndDate,
setTaskLabels,
setTaskPriority,
setTaskStatus,
setTaskSubscribers,
} from '@/features/task-drawer/task-drawer.slice';
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
export const useTaskSocketHandlers = () => {
const dispatch = useAppDispatch();
const { socket } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const { loadingAssignees, taskGroups } = useAppSelector((state: any) => state.taskReducer);
const { projectId } = useAppSelector((state: any) => state.projectReducer);
// Memoize socket event handlers
const handleAssigneesUpdate = useCallback(
(data: ITaskAssigneesUpdateResponse) => {
if (!data) return;
const updatedAssignees = data.assignees?.map(assignee => ({
...assignee,
selected: true,
})) || [];
const groupId = taskGroups?.find((group: ITaskListGroup) =>
group.tasks?.some(
(task: IProjectTask) =>
task.id === data.id ||
(task.sub_tasks && task.sub_tasks.some((subtask: IProjectTask) => subtask.id === data.id))
)
)?.id;
if (groupId) {
dispatch(
updateTaskAssignees({
groupId,
taskId: data.id,
assignees: updatedAssignees,
})
);
dispatch(
setTaskAssignee({
...data,
manual_progress: false,
} as IProjectTask)
);
if (currentSession?.team_id && !loadingAssignees) {
dispatch(fetchTaskAssignees(currentSession.team_id));
}
}
},
[taskGroups, dispatch, currentSession?.team_id, loadingAssignees]
);
const handleLabelsChange = useCallback(
async (labels: ILabelsChangeResponse) => {
if (!labels) return;
await Promise.all([
dispatch(updateTaskLabel(labels)),
dispatch(setTaskLabels(labels)),
dispatch(fetchLabels()),
projectId && dispatch(fetchLabelsByProject(projectId)),
]);
},
[dispatch, projectId]
);
const handleTaskStatusChange = useCallback(
(response: ITaskListStatusChangeResponse) => {
if (!response) return;
if (response.completed_deps === false) {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
return;
}
dispatch(updateTaskStatus(response));
dispatch(deselectAll());
},
[dispatch]
);
const handleTaskProgress = useCallback(
(data: {
id: string;
status: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
if (!data) return;
dispatch(
updateTaskProgress({
taskId: data.parent_task || data.id,
progress: data.complete_ratio,
totalTasksCount: data.total_tasks_count,
completedCount: data.completed_count,
})
);
},
[dispatch]
);
const handlePriorityChange = useCallback(
(response: ITaskListPriorityChangeResponse) => {
if (!response) return;
dispatch(updateTaskPriority(response));
dispatch(setTaskPriority(response));
dispatch(deselectAll());
},
[dispatch]
);
const handleEndDateChange = useCallback(
(task: {
id: string;
parent_task: string | null;
end_date: string;
}) => {
if (!task) return;
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
dispatch(updateTaskEndDate({ task: taskWithProgress }));
dispatch(setTaskEndDate(taskWithProgress));
},
[dispatch]
);
const handleTaskNameChange = useCallback(
(data: { id: string; parent_task: string; name: string }) => {
if (!data) return;
dispatch(updateTaskName(data));
},
[dispatch]
);
const handlePhaseChange = useCallback(
(data: ITaskPhaseChangeResponse) => {
if (!data) return;
dispatch(updateTaskPhase(data));
dispatch(deselectAll());
},
[dispatch]
);
const handleStartDateChange = useCallback(
(task: {
id: string;
parent_task: string | null;
start_date: string;
}) => {
if (!task) return;
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
dispatch(updateTaskStartDate({ task: taskWithProgress }));
dispatch(setStartDate(taskWithProgress));
},
[dispatch]
);
const handleTaskSubscribersChange = useCallback(
(data: InlineMember[]) => {
if (!data) return;
dispatch(setTaskSubscribers(data));
},
[dispatch]
);
const handleEstimationChange = useCallback(
(task: {
id: string;
parent_task: string | null;
estimation: number;
}) => {
if (!task) return;
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
dispatch(updateTaskEstimation({ task: taskWithProgress }));
},
[dispatch]
);
const handleTaskDescriptionChange = useCallback(
(data: {
id: string;
parent_task: string;
description: string;
}) => {
if (!data) return;
dispatch(updateTaskDescription(data));
},
[dispatch]
);
const handleNewTaskReceived = useCallback(
(data: IProjectTask) => {
if (!data) return;
if (data.parent_task_id) {
dispatch(updateSubTasks(data));
}
},
[dispatch]
);
const handleTaskProgressUpdated = useCallback(
(data: {
task_id: string;
progress_value?: number;
weight?: number;
}) => {
if (!data || !taskGroups) return;
if (data.progress_value !== undefined) {
for (const group of taskGroups) {
const task = group.tasks?.find((task: IProjectTask) => task.id === data.task_id);
if (task) {
dispatch(
updateTaskProgress({
taskId: data.task_id,
progress: data.progress_value,
totalTasksCount: task.total_tasks_count || 0,
completedCount: task.completed_count || 0,
})
);
break;
}
}
}
},
[dispatch, taskGroups]
);
// Register socket event listeners
useEffect(() => {
if (!socket) return;
const eventHandlers = [
{ event: SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handler: handleAssigneesUpdate },
{ event: SocketEvents.TASK_LABELS_CHANGE.toString(), handler: handleLabelsChange },
{ event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange },
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgress },
{ event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handlePriorityChange },
{ event: SocketEvents.TASK_END_DATE_CHANGE.toString(), handler: handleEndDateChange },
{ event: SocketEvents.TASK_NAME_CHANGE.toString(), handler: handleTaskNameChange },
{ event: SocketEvents.TASK_PHASE_CHANGE.toString(), handler: handlePhaseChange },
{ event: SocketEvents.TASK_START_DATE_CHANGE.toString(), handler: handleStartDateChange },
{ event: SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handler: handleTaskSubscribersChange },
{ event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handler: handleEstimationChange },
{ event: SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handler: handleTaskDescriptionChange },
{ event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
];
// Register all event listeners
eventHandlers.forEach(({ event, handler }) => {
socket.on(event, handler);
});
// Cleanup function
return () => {
eventHandlers.forEach(({ event, handler }) => {
socket.off(event, handler);
});
};
}, [
socket,
handleAssigneesUpdate,
handleLabelsChange,
handleTaskStatusChange,
handleTaskProgress,
handlePriorityChange,
handleEndDateChange,
handleTaskNameChange,
handlePhaseChange,
handleStartDateChange,
handleTaskSubscribersChange,
handleEstimationChange,
handleTaskDescriptionChange,
handleNewTaskReceived,
handleTaskProgressUpdated,
]);
};

View File

@@ -110,14 +110,14 @@ export const settingsItems: SettingMenuItems[] = [
element: React.createElement(CategoriesSettings),
adminOnly: true,
},
// {
// key: 'project-templates',
// name: 'project-templates',
// endpoint: 'project-templates',
// icon: React.createElement(FileZipOutlined),
// element: React.createElement(ProjectTemplatesSettings),
// adminOnly: true,
// },
{
key: 'project-templates',
name: 'project-templates',
endpoint: 'project-templates',
icon: React.createElement(FileZipOutlined),
element: React.createElement(ProjectTemplatesSettings),
adminOnly: true,
},
{
key: 'task-templates',
name: 'task-templates',

View File

@@ -0,0 +1,241 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDroppable } from '@dnd-kit/core';
import Flex from 'antd/es/flex';
import Badge from 'antd/es/badge';
import Button from 'antd/es/button';
import Dropdown from 'antd/es/dropdown';
import Input from 'antd/es/input';
import Typography from 'antd/es/typography';
import { MenuProps } from 'antd/es/menu';
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import Collapsible from '@/components/collapsible/collapsible';
import TaskListTable from '../../task-list-table/task-list-table';
import { IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice';
import { useAuthService } from '@/hooks/useAuth';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
import { ALPHA_CHANNEL } from '@/shared/constants';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import logger from '@/utils/errorLogger';
interface TaskGroupProps {
taskGroup: ITaskListGroup;
groupBy: string;
color: string;
activeId?: string | null;
}
const TaskGroup: React.FC<TaskGroupProps> = ({
taskGroup,
groupBy,
color,
activeId
}) => {
const { t } = useTranslation('task-list-table');
const dispatch = useAppDispatch();
const { trackMixpanelEvent } = useMixpanelTracking();
const isProjectManager = useIsProjectManager();
const currentSession = useAuthService().getCurrentSession();
const [isExpanded, setIsExpanded] = useState(true);
const [isRenaming, setIsRenaming] = useState(false);
const [groupName, setGroupName] = useState(taskGroup.name || '');
const { projectId } = useAppSelector((state: any) => state.projectReducer);
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
// Memoize droppable configuration
const { setNodeRef } = useDroppable({
id: taskGroup.id,
data: {
type: 'group',
groupId: taskGroup.id,
},
});
// Memoize task count
const taskCount = useMemo(() => taskGroup.tasks?.length || 0, [taskGroup.tasks]);
// Memoize dropdown items
const dropdownItems: MenuProps['items'] = useMemo(() => {
if (groupBy !== IGroupBy.STATUS || !isProjectManager) return [];
return [
{
key: 'rename',
label: t('renameText'),
icon: <EditOutlined />,
onClick: () => setIsRenaming(true),
},
{
key: 'change-category',
label: t('changeCategoryText'),
icon: <RetweetOutlined />,
children: [
{
key: 'todo',
label: t('todoText'),
onClick: () => handleStatusCategoryChange('0'),
},
{
key: 'doing',
label: t('doingText'),
onClick: () => handleStatusCategoryChange('1'),
},
{
key: 'done',
label: t('doneText'),
onClick: () => handleStatusCategoryChange('2'),
},
],
},
];
}, [groupBy, isProjectManager, t]);
const handleStatusCategoryChange = async (category: string) => {
if (!projectId || !taskGroup.id) return;
try {
await statusApiService.updateStatus({
id: taskGroup.id,
category_id: category,
project_id: projectId,
});
dispatch(fetchStatuses());
trackMixpanelEvent(evt_project_board_column_setting_click, {
column_id: taskGroup.id,
action: 'change_category',
category,
});
} catch (error) {
logger.error('Error updating status category:', error);
}
};
const handleRename = async () => {
if (!projectId || !taskGroup.id || !groupName.trim()) return;
try {
if (groupBy === IGroupBy.STATUS) {
await statusApiService.updateStatus({
id: taskGroup.id,
name: groupName.trim(),
project_id: projectId,
});
dispatch(fetchStatuses());
} else if (groupBy === IGroupBy.PHASE) {
const phaseData: ITaskPhase = {
id: taskGroup.id,
name: groupName.trim(),
project_id: projectId,
color_code: taskGroup.color_code,
};
await phasesApiService.updatePhase(phaseData);
dispatch(fetchPhasesByProjectId(projectId));
}
setIsRenaming(false);
} catch (error) {
logger.error('Error renaming group:', error);
}
};
const handleColorChange = async (newColor: string) => {
if (!projectId || !taskGroup.id) return;
try {
const baseColor = newColor.endsWith(ALPHA_CHANNEL)
? newColor.slice(0, -ALPHA_CHANNEL.length)
: newColor;
if (groupBy === IGroupBy.PHASE) {
const phaseData: ITaskPhase = {
id: taskGroup.id,
name: taskGroup.name || '',
project_id: projectId,
color_code: baseColor,
};
await phasesApiService.updatePhase(phaseData);
dispatch(fetchPhasesByProjectId(projectId));
}
dispatch(updateTaskGroupColor({
groupId: taskGroup.id,
color: baseColor,
}));
} catch (error) {
logger.error('Error updating group color:', error);
}
};
return (
<div ref={setNodeRef}>
<Flex vertical>
{/* Group Header */}
<Flex style={{ transform: 'translateY(6px)' }}>
<Button
className="custom-collapse-button"
style={{
backgroundColor: color,
border: 'none',
borderBottomLeftRadius: isExpanded ? 0 : 4,
borderBottomRightRadius: isExpanded ? 0 : 4,
color: colors.darkGray,
minWidth: 200,
}}
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
onClick={() => setIsExpanded(!isExpanded)}
>
{isRenaming ? (
<Input
size="small"
value={groupName}
onChange={e => setGroupName(e.target.value)}
onBlur={handleRename}
onPressEnter={handleRename}
onClick={e => e.stopPropagation()}
autoFocus
/>
) : (
<Typography.Text style={{ fontSize: 14, fontWeight: 600 }}>
{taskGroup.name} ({taskCount})
</Typography.Text>
)}
</Button>
{dropdownItems.length > 0 && !isRenaming && (
<Dropdown menu={{ items: dropdownItems }} trigger={['click']}>
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
</Dropdown>
)}
</Flex>
{/* Task List */}
<Collapsible isOpen={isExpanded}>
<TaskListTable
taskList={taskGroup.tasks || []}
tableId={taskGroup.id}
groupBy={groupBy}
color={color}
activeId={activeId}
/>
</Collapsible>
</Flex>
</div>
);
};
export default React.memo(TaskGroup);

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import Flex from 'antd/es/flex';
import Skeleton from 'antd/es/skeleton';
import { useSearchParams } from 'react-router-dom';
import TaskListFilters from './task-list-filters/task-list-filters';
import TaskGroupWrapper from './task-list-table/task-group-wrapper/task-group-wrapper';
import TaskGroupWrapperOptimized from './task-group-wrapper-optimized';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
@@ -17,64 +17,116 @@ const ProjectViewTaskList = () => {
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const [searchParams, setSearchParams] = useSearchParams();
// Add local loading state to immediately show skeleton
const [isLoading, setIsLoading] = useState(true);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const { projectId } = useAppSelector(state => state.projectReducer);
const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector(
state => state.taskReducer
);
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
state => state.taskStatusReducer
);
const { loadingPhases } = useAppSelector(state => state.phaseReducer);
const { loadingColumns } = useAppSelector(state => state.taskReducer);
// Combine related selectors to reduce subscriptions
const {
projectId,
taskGroups,
loadingGroups,
groupBy,
archived,
fields,
search,
} = useAppSelector(state => ({
projectId: state.projectReducer.projectId,
taskGroups: state.taskReducer.taskGroups,
loadingGroups: state.taskReducer.loadingGroups,
groupBy: state.taskReducer.groupBy,
archived: state.taskReducer.archived,
fields: state.taskReducer.fields,
search: state.taskReducer.search,
}));
const {
statusCategories,
loading: loadingStatusCategories,
} = useAppSelector(state => ({
statusCategories: state.taskStatusReducer.statusCategories,
loading: state.taskStatusReducer.loading,
}));
const { loadingPhases } = useAppSelector(state => ({
loadingPhases: state.phaseReducer.loadingPhases,
}));
// Single source of truth for loading state - EXCLUDE labels loading from skeleton
// Labels loading should not block the main task list display
const isLoading = useMemo(() =>
loadingGroups || loadingPhases || loadingStatusCategories || !initialLoadComplete,
[loadingGroups, loadingPhases, loadingStatusCategories, initialLoadComplete]
);
// Memoize the empty state check
const isEmptyState = useMemo(() =>
taskGroups && taskGroups.length === 0 && !isLoading,
[taskGroups, isLoading]
);
// Handle view type changes
useEffect(() => {
// Set default view to list if projectView is not list or board
if (projectView !== 'list' && projectView !== 'board') {
searchParams.set('tab', 'tasks-list');
searchParams.set('pinned_tab', 'tasks-list');
setSearchParams(searchParams);
const newParams = new URLSearchParams(searchParams);
newParams.set('tab', 'tasks-list');
newParams.set('pinned_tab', 'tasks-list');
setSearchParams(newParams);
}
}, [projectView, searchParams, setSearchParams]);
}, [projectView, setSearchParams, searchParams]);
// Batch initial data fetching - core data only
useEffect(() => {
// Set loading state based on all loading conditions
setIsLoading(loadingGroups || loadingColumns || loadingPhases || loadingStatusCategories);
}, [loadingGroups, loadingColumns, loadingPhases, loadingStatusCategories]);
const fetchInitialData = async () => {
if (!projectId || !groupBy || initialLoadComplete) return;
useEffect(() => {
const loadData = async () => {
if (projectId && groupBy) {
const promises = [];
if (!loadingColumns) promises.push(dispatch(fetchTaskListColumns(projectId)));
if (!loadingPhases) promises.push(dispatch(fetchPhasesByProjectId(projectId)));
if (!loadingGroups && projectView === 'list') {
promises.push(dispatch(fetchTaskGroups(projectId)));
}
if (!statusCategories.length) {
promises.push(dispatch(fetchStatusesCategories()));
}
// Wait for all data to load
await Promise.all(promises);
try {
// Batch only essential API calls for initial load
// Filter data (labels, assignees, etc.) will load separately and not block the UI
await Promise.allSettled([
dispatch(fetchTaskListColumns(projectId)),
dispatch(fetchPhasesByProjectId(projectId)),
dispatch(fetchStatusesCategories()),
]);
setInitialLoadComplete(true);
} catch (error) {
console.error('Error fetching initial data:', error);
setInitialLoadComplete(true); // Still mark as complete to prevent infinite loading
}
};
loadData();
}, [dispatch, projectId, groupBy, fields, search, archived]);
fetchInitialData();
}, [projectId, groupBy, dispatch, initialLoadComplete]);
// Fetch task groups with dependency on initial load completion
useEffect(() => {
const fetchTasks = async () => {
if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return;
try {
await dispatch(fetchTaskGroups(projectId));
} catch (error) {
console.error('Error fetching task groups:', error);
}
};
fetchTasks();
}, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]);
// Memoize the task groups to prevent unnecessary re-renders
const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]);
return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
{/* Filters load independently and don't block the main content */}
<TaskListFilters position="list" />
{(taskGroups && taskGroups.length === 0 && !isLoading) ? (
{isEmptyState ? (
<Empty description="No tasks group found" />
) : (
<Skeleton active loading={isLoading} className='mt-4 p-4'>
<TaskGroupWrapper taskGroups={taskGroups} groupBy={groupBy} />
<TaskGroupWrapperOptimized
taskGroups={memoizedTaskGroups}
groupBy={groupBy}
/>
</Skeleton>
)}
</Flex>

View File

@@ -0,0 +1,112 @@
import React, { useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import Flex from 'antd/es/flex';
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
import {
DndContext,
pointerWithin,
} from '@dnd-kit/core';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper';
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { useTaskDragAndDrop } from '@/hooks/useTaskDragAndDrop';
interface TaskGroupWrapperOptimizedProps {
taskGroups: ITaskListGroup[];
groupBy: string;
}
const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOptimizedProps) => {
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
// Use extracted hooks
useTaskSocketHandlers();
const {
activeId,
sensors,
handleDragStart,
handleDragEnd,
handleDragOver,
resetTaskRowStyles,
} = useTaskDragAndDrop({ taskGroups, groupBy });
// Memoize task groups with colors
const taskGroupsWithColors = useMemo(() =>
taskGroups?.map(taskGroup => ({
...taskGroup,
displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code,
})) || [],
[taskGroups, themeMode]
);
// Add drag styles
useEffect(() => {
const style = document.createElement('style');
style.textContent = `
.task-row[data-is-dragging="true"] {
opacity: 0.5 !important;
transform: rotate(5deg) !important;
z-index: 1000 !important;
position: relative !important;
}
.task-row {
transition: transform 0.2s ease, opacity 0.2s ease;
}
`;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
// Handle animation cleanup after drag ends
useIsomorphicLayoutEffect(() => {
if (activeId === null) {
const timeoutId = setTimeout(resetTaskRowStyles, 50);
return () => clearTimeout(timeoutId);
}
}, [activeId, resetTaskRowStyles]);
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
>
<Flex gap={24} vertical>
{taskGroupsWithColors.map(taskGroup => (
<TaskListTableWrapper
key={taskGroup.id}
taskList={taskGroup.tasks}
tableId={taskGroup.id}
name={taskGroup.name}
groupBy={groupBy}
statusCategory={taskGroup.category_id}
color={taskGroup.displayColor}
activeId={activeId}
/>
))}
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
{createPortal(
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
document.body,
'task-template-drawer'
)}
</Flex>
</DndContext>
);
};
export default React.memo(TaskGroupWrapperOptimized);

View File

@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
import {
fetchLabelsByProject,
fetchTaskAssignees,
@@ -33,23 +34,49 @@ const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
const { projectView } = useTabSearchParam();
const priorities = useAppSelector(state => state.priorityReducer.priorities);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const archived = useAppSelector(state => state.taskReducer.archived);
const handleShowArchivedChange = () => dispatch(toggleArchived());
// Load filter data asynchronously and non-blocking
// This runs independently of the main task list loading
useEffect(() => {
const fetchInitialData = async () => {
if (!priorities.length) await dispatch(fetchPriorities());
if (projectId) {
await dispatch(fetchLabelsByProject(projectId));
await dispatch(fetchTaskAssignees(projectId));
const loadFilterData = async () => {
try {
// Load priorities first (usually cached/fast)
if (!priorities.length) {
dispatch(fetchPriorities());
}
// Load project-specific filter data in parallel, but don't await
// This allows the main task list to load while filters are still loading
if (projectId) {
// Fire and forget - these will update the UI when ready
dispatch(fetchLabelsByProject(projectId));
dispatch(fetchTaskAssignees(projectId));
}
// Load team members (usually needed for member filters)
dispatch(getTeamMembers({
index: 0,
size: 100,
field: null,
order: null,
search: null,
all: true
}));
} catch (error) {
console.error('Error loading filter data:', error);
// Don't throw - filter loading errors shouldn't break the main UI
}
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
};
fetchInitialData();
// Use setTimeout to ensure this runs after the main component render
// This prevents filter loading from blocking the initial render
const timeoutId = setTimeout(loadFilterData, 0);
return () => clearTimeout(timeoutId);
}, [dispatch, priorities.length, projectId]);
return (

View File

@@ -2,7 +2,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import Flex from 'antd/es/flex';
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
@@ -87,16 +87,22 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
const { projectId } = useAppSelector(state => state.projectReducer);
const sensors = useSensors(
useSensor(PointerSensor, {
// Move useSensors to top level and memoize its configuration
const sensorConfig = useMemo(
() => ({
activationConstraint: { distance: 8 },
})
}),
[]
);
const pointerSensor = useSensor(PointerSensor, sensorConfig);
const sensors = useSensors(pointerSensor);
useEffect(() => {
setGroups(taskGroups);
}, [taskGroups]);
// Memoize resetTaskRowStyles to prevent unnecessary re-renders
const resetTaskRowStyles = useCallback(() => {
document.querySelectorAll<HTMLElement>('.task-row').forEach(row => {
row.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
@@ -106,21 +112,18 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
});
}, []);
// Socket handler for assignee updates
useEffect(() => {
if (!socket) return;
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
// Memoize socket event handlers
const handleAssigneesUpdate = useCallback(
(data: ITaskAssigneesUpdateResponse) => {
if (!data) return;
const updatedAssignees = data.assignees.map(assignee => ({
const updatedAssignees = data.assignees?.map(assignee => ({
...assignee,
selected: true,
}));
})) || [];
// Find the group that contains the task or its subtasks
const groupId = groups.find(group =>
group.tasks.some(
const groupId = groups?.find(group =>
group.tasks?.some(
task =>
task.id === data.id ||
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id))
@@ -136,47 +139,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
})
);
dispatch(setTaskAssignee(data));
dispatch(
setTaskAssignee({
...data,
manual_progress: false,
} as IProjectTask)
);
if (currentSession?.team_id && !loadingAssignees) {
dispatch(fetchTaskAssignees(currentSession.team_id));
}
}
};
},
[groups, dispatch, currentSession?.team_id, loadingAssignees]
);
socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
return () => {
socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
};
}, [socket, currentSession?.team_id, loadingAssignees, groups, dispatch]);
// Memoize socket event handlers
const handleLabelsChange = useCallback(
async (labels: ILabelsChangeResponse) => {
if (!labels) return;
// Socket handler for label updates
useEffect(() => {
if (!socket) return;
const handleLabelsChange = async (labels: ILabelsChangeResponse) => {
await Promise.all([
dispatch(updateTaskLabel(labels)),
dispatch(setTaskLabels(labels)),
dispatch(fetchLabels()),
projectId && dispatch(fetchLabelsByProject(projectId)),
]);
};
},
[dispatch, projectId]
);
socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
// Memoize socket event handlers
const handleTaskStatusChange = useCallback(
(response: ITaskListStatusChangeResponse) => {
if (!response) return;
return () => {
socket.off(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
socket.off(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
};
}, [socket, dispatch, projectId]);
// Socket handler for status updates
useEffect(() => {
if (!socket) return;
const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => {
if (response.completed_deps === false) {
alertService.error(
'Task is not completed',
@@ -186,11 +183,14 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
}
dispatch(updateTaskStatus(response));
// dispatch(setTaskStatus(response));
dispatch(deselectAll());
};
},
[dispatch]
);
const handleTaskProgress = (data: {
// Memoize socket event handlers
const handleTaskProgress = useCallback(
(data: {
id: string;
status: string;
complete_ratio: number;
@@ -198,6 +198,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
total_tasks_count: number;
parent_task: string;
}) => {
if (!data) return;
dispatch(
updateTaskProgress({
taskId: data.parent_task || data.id,
@@ -206,190 +208,150 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
completedCount: data.completed_count,
})
);
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
// Memoize socket event handlers
const handlePriorityChange = useCallback(
(response: ITaskListPriorityChangeResponse) => {
if (!response) return;
return () => {
socket.off(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
socket.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
};
}, [socket, dispatch]);
// Socket handler for priority updates
useEffect(() => {
if (!socket) return;
const handlePriorityChange = (response: ITaskListPriorityChangeResponse) => {
dispatch(updateTaskPriority(response));
dispatch(setTaskPriority(response));
dispatch(deselectAll());
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
return () => {
socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
};
}, [socket, dispatch]);
// Socket handler for due date updates
useEffect(() => {
if (!socket) return;
const handleEndDateChange = (task: {
// Memoize socket event handlers
const handleEndDateChange = useCallback(
(task: {
id: string;
parent_task: string | null;
end_date: string;
}) => {
dispatch(updateTaskEndDate({ task }));
dispatch(setTaskEndDate(task));
};
if (!task) return;
socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
return () => {
socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
};
}, [socket, dispatch]);
dispatch(updateTaskEndDate({ task: taskWithProgress }));
dispatch(setTaskEndDate(taskWithProgress));
},
[dispatch]
);
// Socket handler for task name updates
useEffect(() => {
if (!socket) return;
// Memoize socket event handlers
const handleTaskNameChange = useCallback(
(data: { id: string; parent_task: string; name: string }) => {
if (!data) return;
const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => {
dispatch(updateTaskName(data));
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
// Memoize socket event handlers
const handlePhaseChange = useCallback(
(data: ITaskPhaseChangeResponse) => {
if (!data) return;
return () => {
socket.off(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
};
}, [socket, dispatch]);
// Socket handler for phase updates
useEffect(() => {
if (!socket) return;
const handlePhaseChange = (data: ITaskPhaseChangeResponse) => {
dispatch(updateTaskPhase(data));
dispatch(deselectAll());
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
return () => {
socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
};
}, [socket, dispatch]);
// Socket handler for start date updates
useEffect(() => {
if (!socket) return;
const handleStartDateChange = (task: {
// Memoize socket event handlers
const handleStartDateChange = useCallback(
(task: {
id: string;
parent_task: string | null;
start_date: string;
}) => {
dispatch(updateTaskStartDate({ task }));
dispatch(setStartDate(task));
};
if (!task) return;
socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
return () => {
socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
};
}, [socket, dispatch]);
dispatch(updateTaskStartDate({ task: taskWithProgress }));
dispatch(setStartDate(taskWithProgress));
},
[dispatch]
);
// Socket handler for task subscribers updates
useEffect(() => {
if (!socket) return;
// Memoize socket event handlers
const handleTaskSubscribersChange = useCallback(
(data: InlineMember[]) => {
if (!data) return;
const handleTaskSubscribersChange = (data: InlineMember[]) => {
dispatch(setTaskSubscribers(data));
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
return () => {
socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
};
}, [socket, dispatch]);
// Socket handler for task estimation updates
useEffect(() => {
if (!socket) return;
const handleEstimationChange = (task: {
// Memoize socket event handlers
const handleEstimationChange = useCallback(
(task: {
id: string;
parent_task: string | null;
estimation: number;
}) => {
dispatch(updateTaskEstimation({ task }));
};
if (!task) return;
socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
return () => {
socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
};
}, [socket, dispatch]);
dispatch(updateTaskEstimation({ task: taskWithProgress }));
},
[dispatch]
);
// Socket handler for task description updates
useEffect(() => {
if (!socket) return;
const handleTaskDescriptionChange = (data: {
// Memoize socket event handlers
const handleTaskDescriptionChange = useCallback(
(data: {
id: string;
parent_task: string;
description: string;
}) => {
if (!data) return;
dispatch(updateTaskDescription(data));
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
return () => {
socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
};
}, [socket, dispatch]);
// Socket handler for new task creation
useEffect(() => {
if (!socket) return;
const handleNewTaskReceived = (data: IProjectTask) => {
// Memoize socket event handlers
const handleNewTaskReceived = useCallback(
(data: IProjectTask) => {
if (!data) return;
if (data.parent_task_id) {
dispatch(updateSubTasks(data));
}
};
},
[dispatch]
);
socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
return () => {
socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
};
}, [socket, dispatch]);
// Socket handler for task progress updates
useEffect(() => {
if (!socket) return;
const handleTaskProgressUpdated = (data: {
// Memoize socket event handlers
const handleTaskProgressUpdated = useCallback(
(data: {
task_id: string;
progress_value?: number;
weight?: number;
}) => {
if (!data || !taskGroups) return;
if (data.progress_value !== undefined) {
// Find the task in the task groups and update its progress
for (const group of taskGroups) {
const task = group.tasks.find(task => task.id === data.task_id);
const task = group.tasks?.find(task => task.id === data.task_id);
if (task) {
dispatch(
updateTaskProgress({
@@ -403,25 +365,76 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
}
}
}
},
[dispatch, taskGroups]
);
// Set up socket event listeners
useEffect(() => {
if (!socket) return;
const eventHandlers = {
[SocketEvents.QUICK_ASSIGNEES_UPDATE.toString()]: handleAssigneesUpdate,
[SocketEvents.TASK_LABELS_CHANGE.toString()]: handleLabelsChange,
[SocketEvents.CREATE_LABEL.toString()]: handleLabelsChange,
[SocketEvents.TASK_STATUS_CHANGE.toString()]: handleTaskStatusChange,
[SocketEvents.GET_TASK_PROGRESS.toString()]: handleTaskProgress,
[SocketEvents.TASK_PRIORITY_CHANGE.toString()]: handlePriorityChange,
[SocketEvents.TASK_END_DATE_CHANGE.toString()]: handleEndDateChange,
[SocketEvents.TASK_NAME_CHANGE.toString()]: handleTaskNameChange,
[SocketEvents.TASK_PHASE_CHANGE.toString()]: handlePhaseChange,
[SocketEvents.TASK_START_DATE_CHANGE.toString()]: handleStartDateChange,
[SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString()]: handleTaskSubscribersChange,
[SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString()]: handleEstimationChange,
[SocketEvents.TASK_DESCRIPTION_CHANGE.toString()]: handleTaskDescriptionChange,
[SocketEvents.QUICK_TASK.toString()]: handleNewTaskReceived,
[SocketEvents.TASK_PROGRESS_UPDATED.toString()]: handleTaskProgressUpdated,
};
socket.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated);
// Register all event handlers
Object.entries(eventHandlers).forEach(([event, handler]) => {
if (handler) {
socket.on(event, handler);
}
});
// Cleanup function
return () => {
socket.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated);
Object.entries(eventHandlers).forEach(([event, handler]) => {
if (handler) {
socket.off(event, handler);
}
});
};
}, [socket, dispatch, taskGroups]);
}, [
socket,
handleAssigneesUpdate,
handleLabelsChange,
handleTaskStatusChange,
handleTaskProgress,
handlePriorityChange,
handleEndDateChange,
handleTaskNameChange,
handlePhaseChange,
handleStartDateChange,
handleTaskSubscribersChange,
handleEstimationChange,
handleTaskDescriptionChange,
handleNewTaskReceived,
handleTaskProgressUpdated,
]);
// Memoize drag handlers
const handleDragStart = useCallback(({ active }: DragStartEvent) => {
setActiveId(active.id as string);
// Add smooth transition to the dragged item
const draggedElement = document.querySelector(`[data-id="${active.id}"]`);
if (draggedElement) {
(draggedElement as HTMLElement).style.transition = 'transform 0.2s ease';
}
}, []);
// Memoize drag handlers
const handleDragEnd = useCallback(
async ({ active, over }: DragEndEvent) => {
setActiveId(null);
@@ -440,10 +453,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
if (fromIndex === -1) return;
// Create a deep clone of the task to avoid reference issues
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
// Check if task dependencies allow the move
if (activeGroupId !== overGroupId) {
const canContinue = await checkTaskDependencyStatus(task.id, overGroupId);
if (!canContinue) {
@@ -455,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
return;
}
// Update task properties based on target group
switch (groupBy) {
case IGroupBy.STATUS:
task.status = overGroupId;
@@ -468,35 +478,29 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
task.priority_color_dark = targetGroup.color_code_dark;
break;
case IGroupBy.PHASE:
// Check if ALPHA_CHANNEL is already added
const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL)
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL
: targetGroup.color_code; // Use as is if not present
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length)
: targetGroup.color_code;
task.phase_id = overGroupId;
task.phase_color = baseColor; // Set the cleaned color
task.phase_color = baseColor;
break;
}
}
const isTargetGroupEmpty = targetGroup.tasks.length === 0;
// Calculate toIndex - for empty groups, always add at index 0
const toIndex = isTargetGroupEmpty
? 0
: overTaskId
? targetGroup.tasks.findIndex(t => t.id === overTaskId)
: targetGroup.tasks.length;
// Calculate toPos similar to Angular implementation
const toPos = isTargetGroupEmpty
? -1
: targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
// Update Redux state
if (activeGroupId === overGroupId) {
// Same group - move within array
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
@@ -514,7 +518,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
},
});
} else {
// Different groups - transfer between arrays
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
@@ -540,7 +543,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
});
}
// Emit socket event
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: sourceGroup.tasks[fromIndex].sort_order,
@@ -549,13 +551,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
from_group: sourceGroup.id,
to_group: targetGroup.id,
group_by: groupBy,
task: sourceGroup.tasks[fromIndex], // Send original task to maintain references
task: sourceGroup.tasks[fromIndex],
team_id: currentSession?.team_id,
});
// Reset styles
setTimeout(resetTaskRowStyles, 0);
trackMixpanelEvent(evt_project_task_list_drag_and_move);
},
[
@@ -570,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
]
);
// Memoize drag handlers
const handleDragOver = useCallback(
({ active, over }: DragEndEvent) => {
if (!over) return;
@@ -589,12 +590,9 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
if (fromIndex === -1 || toIndex === -1) return;
// Create a deep clone of the task to avoid reference issues
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
// Update Redux state
if (activeGroupId === overGroupId) {
// Same group - move within array
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
@@ -612,10 +610,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
},
});
} else {
// Different groups - transfer between arrays
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
updatedTargetTasks.splice(toIndex, 0, task);
dispatch({
@@ -663,7 +659,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
// Handle animation cleanup after drag ends
useIsomorphicLayoutEffect(() => {
if (activeId === null) {
// Final cleanup after React updates DOM
const timeoutId = setTimeout(resetTaskRowStyles, 50);
return () => clearTimeout(timeoutId);
}

View File

@@ -51,7 +51,7 @@ const ProjectTemplatesSettings = () => {
style={{ display: 'flex', gap: '10px', justifyContent: 'right' }}
className="button-visibilty"
>
<Tooltip title={t('editToolTip')}>
{/* <Tooltip title={t('editToolTip')}>
<Button
size="small"
onClick={() =>
@@ -60,7 +60,7 @@ const ProjectTemplatesSettings = () => {
>
<EditOutlined />
</Button>
</Tooltip>
</Tooltip> */}
<Tooltip title={t('deleteToolTip')}>
<Popconfirm
title={