feat(project-drawer): enhance project data fetching and error handling
- Updated project data fetching logic in the project drawer and related components to ensure the drawer opens only after successful data retrieval. - Added detailed logging for successful and failed fetch attempts to improve debugging and user feedback. - Introduced error handling to maintain user experience by allowing the drawer to open even if data fetching fails, displaying an error state. - Refactored project list and project view components to optimize search functionality and improve loading states. - Removed deprecated components related to task management to streamline the project view.
This commit is contained in:
26
worklenz-frontend/package-lock.json
generated
26
worklenz-frontend/package-lock.json
generated
@@ -2314,6 +2314,32 @@
|
||||
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.81.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.81.5.tgz",
|
||||
"integrity": "sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.81.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz",
|
||||
"integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.81.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
|
||||
@@ -124,10 +124,25 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
||||
// Action handlers
|
||||
const handleSettingsClick = (e: React.MouseEvent, projectId: string) => {
|
||||
e.stopPropagation();
|
||||
console.log('Opening project drawer from project group for project:', projectId);
|
||||
trackMixpanelEvent(evt_projects_settings_click);
|
||||
|
||||
// Set project ID first
|
||||
dispatch(setProjectId(projectId));
|
||||
dispatch(fetchProjectData(projectId));
|
||||
|
||||
// Then fetch project data
|
||||
dispatch(fetchProjectData(projectId))
|
||||
.unwrap()
|
||||
.then((projectData) => {
|
||||
console.log('Project data fetched successfully from project group:', projectData);
|
||||
// Open drawer after data is fetched
|
||||
dispatch(toggleProjectDrawer());
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch project data from project group:', error);
|
||||
// Still open drawer even if fetch fails, so user can see error state
|
||||
dispatch(toggleProjectDrawer());
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveClick = async (
|
||||
|
||||
@@ -46,10 +46,25 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
if (record.id) {
|
||||
console.log('Opening project drawer for project:', record.id);
|
||||
trackMixpanelEvent(evt_projects_settings_click);
|
||||
|
||||
// Set project ID first
|
||||
dispatch(setProjectId(record.id));
|
||||
dispatch(fetchProjectData(record.id));
|
||||
|
||||
// Then fetch project data
|
||||
dispatch(fetchProjectData(record.id))
|
||||
.unwrap()
|
||||
.then((projectData) => {
|
||||
console.log('Project data fetched successfully:', projectData);
|
||||
// Open drawer after data is fetched
|
||||
dispatch(toggleProjectDrawer());
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch project data:', error);
|
||||
// Still open drawer even if fetch fails, so user can see error state
|
||||
dispatch(toggleProjectDrawer());
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
null
|
||||
);
|
||||
const [isFormValid, setIsFormValid] = useState<boolean>(true);
|
||||
const [drawerVisible, setDrawerVisible] = useState<boolean>(false);
|
||||
|
||||
// Selectors
|
||||
const { clients, loading: loadingClients } = useAppSelector(state => state.clientReducer);
|
||||
@@ -131,6 +132,60 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
loadInitialData();
|
||||
}, [dispatch]);
|
||||
|
||||
// New effect to handle form population when project data becomes available
|
||||
useEffect(() => {
|
||||
if (drawerVisible && projectId && project && !projectLoading) {
|
||||
console.log('Populating form with project data:', project);
|
||||
setEditMode(true);
|
||||
|
||||
try {
|
||||
form.setFieldsValue({
|
||||
...project,
|
||||
start_date: project.start_date ? dayjs(project.start_date) : null,
|
||||
end_date: project.end_date ? dayjs(project.end_date) : null,
|
||||
working_days: project.working_days || 0,
|
||||
use_manual_progress: project.use_manual_progress || false,
|
||||
use_weighted_progress: project.use_weighted_progress || false,
|
||||
use_time_progress: project.use_time_progress || false,
|
||||
});
|
||||
|
||||
setSelectedProjectManager(project.project_manager || null);
|
||||
setLoading(false);
|
||||
console.log('Form populated successfully with project data');
|
||||
} catch (error) {
|
||||
console.error('Error setting form values:', error);
|
||||
logger.error('Error setting form values in project drawer', error);
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (drawerVisible && !projectId) {
|
||||
// Creating new project
|
||||
console.log('Setting up drawer for new project creation');
|
||||
setEditMode(false);
|
||||
setLoading(false);
|
||||
} else if (drawerVisible && projectId && !project && !projectLoading) {
|
||||
// Project data failed to load or is empty
|
||||
console.warn('Project drawer is visible but no project data available');
|
||||
setLoading(false);
|
||||
} else if (drawerVisible && projectId) {
|
||||
console.log('Drawer visible, waiting for project data to load...');
|
||||
}
|
||||
}, [drawerVisible, projectId, project, projectLoading, form]);
|
||||
|
||||
// Additional effect to handle loading state when project data is being fetched
|
||||
useEffect(() => {
|
||||
if (drawerVisible && projectId && projectLoading) {
|
||||
console.log('Project data is loading, maintaining loading state');
|
||||
setLoading(true);
|
||||
}
|
||||
}, [drawerVisible, projectId, projectLoading]);
|
||||
|
||||
// Define resetForm function early to avoid declaration order issues
|
||||
const resetForm = useCallback(() => {
|
||||
setEditMode(false);
|
||||
form.resetFields();
|
||||
setSelectedProjectManager(null);
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
const startDate = form.getFieldValue('start_date');
|
||||
const endDate = form.getFieldValue('end_date');
|
||||
@@ -226,47 +281,33 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
return workingDays;
|
||||
};
|
||||
|
||||
// Improved handleVisibilityChange to track drawer state without doing form operations
|
||||
const handleVisibilityChange = useCallback(
|
||||
(visible: boolean) => {
|
||||
if (visible && projectId) {
|
||||
setEditMode(true);
|
||||
if (project) {
|
||||
form.setFieldsValue({
|
||||
...project,
|
||||
start_date: project.start_date ? dayjs(project.start_date) : null,
|
||||
end_date: project.end_date ? dayjs(project.end_date) : null,
|
||||
working_days:
|
||||
form.getFieldValue('start_date') && form.getFieldValue('end_date')
|
||||
? calculateWorkingDays(
|
||||
form.getFieldValue('start_date'),
|
||||
form.getFieldValue('end_date')
|
||||
)
|
||||
: project.working_days || 0,
|
||||
use_manual_progress: project.use_manual_progress || false,
|
||||
use_weighted_progress: project.use_weighted_progress || false,
|
||||
use_time_progress: project.use_time_progress || false,
|
||||
});
|
||||
setSelectedProjectManager(project.project_manager || null);
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
console.log('Drawer visibility changed:', visible, 'Project ID:', projectId);
|
||||
setDrawerVisible(visible);
|
||||
|
||||
if (!visible) {
|
||||
resetForm();
|
||||
} else if (visible && !projectId) {
|
||||
// Creating new project - reset form immediately
|
||||
console.log('Opening drawer for new project');
|
||||
setEditMode(false);
|
||||
setLoading(false);
|
||||
} else if (visible && projectId) {
|
||||
// Editing existing project - loading state will be handled by useEffect
|
||||
console.log('Opening drawer for existing project:', projectId);
|
||||
setLoading(true);
|
||||
}
|
||||
},
|
||||
[projectId, project]
|
||||
[projectId, resetForm]
|
||||
);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setEditMode(false);
|
||||
form.resetFields();
|
||||
setSelectedProjectManager(null);
|
||||
}, [form]);
|
||||
|
||||
const handleDrawerClose = useCallback(() => {
|
||||
setLoading(true);
|
||||
setDrawerVisible(false);
|
||||
resetForm();
|
||||
dispatch(setProjectData({} as IProjectViewModel));
|
||||
// dispatch(setProjectId(null));
|
||||
dispatch(setDrawerProjectId(null));
|
||||
dispatch(toggleProjectDrawer());
|
||||
onClose();
|
||||
@@ -405,7 +446,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
{!isEditable && (
|
||||
<Alert message={t('noPermission')} type="warning" showIcon style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
<Skeleton active paragraph={{ rows: 12 }} loading={projectLoading}>
|
||||
<Skeleton active paragraph={{ rows: 12 }} loading={loading || projectLoading}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
|
||||
@@ -20,10 +20,31 @@ export const fetchProjectData = createAsyncThunk(
|
||||
'project/fetchProjectData',
|
||||
async (projectId: string, { rejectWithValue, dispatch }) => {
|
||||
try {
|
||||
if (!projectId) {
|
||||
throw new Error('Project ID is required');
|
||||
}
|
||||
|
||||
console.log(`Fetching project data for ID: ${projectId}`);
|
||||
const response = await projectsApiService.getProject(projectId);
|
||||
|
||||
if (!response) {
|
||||
throw new Error('No response received from API');
|
||||
}
|
||||
|
||||
if (!response.done) {
|
||||
throw new Error(response.message || 'API request failed');
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No project data in response body');
|
||||
}
|
||||
|
||||
console.log(`Successfully fetched project data:`, response.body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
return rejectWithValue(error instanceof Error ? error.message : 'Failed to fetch project');
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch project';
|
||||
console.error(`Error fetching project data for ID ${projectId}:`, error);
|
||||
return rejectWithValue(errorMessage);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -44,16 +65,21 @@ const projectDrawerSlice = createSlice({
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
|
||||
.addCase(fetchProjectData.pending, state => {
|
||||
console.log('Starting project data fetch...');
|
||||
state.projectLoading = true;
|
||||
state.project = null; // Clear existing data while loading
|
||||
})
|
||||
.addCase(fetchProjectData.fulfilled, (state, action) => {
|
||||
console.log('Project data fetch completed successfully:', action.payload);
|
||||
state.project = action.payload;
|
||||
state.projectLoading = false;
|
||||
})
|
||||
.addCase(fetchProjectData.rejected, state => {
|
||||
.addCase(fetchProjectData.rejected, (state, action) => {
|
||||
console.error('Project data fetch failed:', action.payload);
|
||||
state.projectLoading = false;
|
||||
state.project = null;
|
||||
// You could add an error field to the state if needed for UI feedback
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { ReactNode, Suspense } from 'react';
|
||||
import { InlineSuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
|
||||
|
||||
// Import core components synchronously to avoid suspense in main tabs
|
||||
import ProjectViewEnhancedTasks from '@/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks';
|
||||
import ProjectViewEnhancedBoard from '@/pages/projects/projectView/enhancedBoard/project-view-enhanced-board';
|
||||
import TaskListV2 from '@/components/task-list-v2/TaskListV2';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ProjectViewType, ProjectGroupBy } from '@/types/project/project.types';
|
||||
@@ -85,6 +85,10 @@ const createFilters = (items: { id: string; name: string }[]) =>
|
||||
const ProjectList: React.FC = () => {
|
||||
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastQueryParamsRef = useRef<string>('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const { t } = useTranslation('all-project-list');
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -103,12 +107,130 @@ const ProjectList: React.FC = () => {
|
||||
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
|
||||
const { filteredCategories, filteredStatuses } = useAppSelector(state => state.projectsReducer);
|
||||
|
||||
// Optimize query parameters to prevent unnecessary re-renders
|
||||
const optimizedQueryParams = useMemo(() => {
|
||||
const params = {
|
||||
index: requestParams.index,
|
||||
size: requestParams.size,
|
||||
field: requestParams.field,
|
||||
order: requestParams.order,
|
||||
search: requestParams.search,
|
||||
filter: requestParams.filter,
|
||||
statuses: requestParams.statuses,
|
||||
categories: requestParams.categories,
|
||||
};
|
||||
|
||||
// Create a stable key for comparison
|
||||
const paramsKey = JSON.stringify(params);
|
||||
|
||||
// Only return new params if they've actually changed
|
||||
if (paramsKey !== lastQueryParamsRef.current) {
|
||||
lastQueryParamsRef.current = paramsKey;
|
||||
return params;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [requestParams]);
|
||||
|
||||
// Use the optimized query with better error handling and caching
|
||||
const {
|
||||
data: projectsData,
|
||||
isLoading: loadingProjects,
|
||||
isFetching: isFetchingProjects,
|
||||
refetch: refetchProjects,
|
||||
} = useGetProjectsQuery(requestParams);
|
||||
error: projectsError,
|
||||
} = useGetProjectsQuery(optimizedQueryParams, {
|
||||
// Enable caching and reduce unnecessary refetches
|
||||
refetchOnMountOrArgChange: 30, // Refetch if data is older than 30 seconds
|
||||
refetchOnFocus: false, // Don't refetch on window focus
|
||||
refetchOnReconnect: true, // Refetch on network reconnect
|
||||
// Skip query if we're in group view mode
|
||||
skip: viewMode === ProjectViewType.GROUP,
|
||||
});
|
||||
|
||||
// Add performance monitoring
|
||||
const performanceRef = useRef<{ startTime: number | null }>({ startTime: null });
|
||||
|
||||
// Monitor query performance
|
||||
useEffect(() => {
|
||||
if (loadingProjects && !performanceRef.current.startTime) {
|
||||
performanceRef.current.startTime = performance.now();
|
||||
} else if (!loadingProjects && performanceRef.current.startTime) {
|
||||
const duration = performance.now() - performanceRef.current.startTime;
|
||||
console.log(`Projects query completed in ${duration.toFixed(2)}ms`);
|
||||
performanceRef.current.startTime = null;
|
||||
}
|
||||
}, [loadingProjects]);
|
||||
|
||||
// Optimized debounced search with better cleanup and performance
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((searchTerm: string) => {
|
||||
console.log('Executing debounced search:', searchTerm);
|
||||
|
||||
// Clear any error messages when starting a new search
|
||||
setErrorMessage(null);
|
||||
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
dispatch(setRequestParams({
|
||||
search: searchTerm,
|
||||
index: 1 // Reset to first page on search
|
||||
}));
|
||||
} else if (viewMode === ProjectViewType.GROUP) {
|
||||
const newGroupedParams = {
|
||||
...groupedRequestParams,
|
||||
search: searchTerm,
|
||||
index: 1,
|
||||
};
|
||||
dispatch(setGroupedRequestParams(newGroupedParams));
|
||||
|
||||
// Add timeout for grouped search to prevent rapid API calls
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
dispatch(fetchGroupedProjects(newGroupedParams));
|
||||
}, 100);
|
||||
}
|
||||
}, 500), // Increased debounce time for better performance
|
||||
[dispatch, viewMode, groupedRequestParams]
|
||||
);
|
||||
|
||||
// Enhanced cleanup with better timeout management
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedSearch.cancel();
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
searchTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [debouncedSearch]);
|
||||
|
||||
// Improved search change handler with better validation
|
||||
const handleSearchChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newSearchValue = e.target.value;
|
||||
|
||||
// Validate input length to prevent excessive API calls
|
||||
if (newSearchValue.length > 100) {
|
||||
return; // Prevent extremely long search terms
|
||||
}
|
||||
|
||||
setSearchValue(newSearchValue);
|
||||
trackMixpanelEvent(evt_projects_search);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
searchTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Debounce the actual search execution
|
||||
debouncedSearch(newSearchValue);
|
||||
},
|
||||
[debouncedSearch, trackMixpanelEvent]
|
||||
);
|
||||
|
||||
const getFilterIndex = useCallback(() => {
|
||||
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
|
||||
@@ -247,14 +369,15 @@ const ProjectList: React.FC = () => {
|
||||
// Memoize the table data source
|
||||
const tableDataSource = useMemo(() => projectsData?.body?.data || [], [projectsData?.body?.data]);
|
||||
|
||||
// Memoize the empty text component
|
||||
const emptyText = useMemo(() => <Empty description={t('noProjects')} />, [t]);
|
||||
|
||||
// Memoize the pagination show total function
|
||||
const paginationShowTotal = useMemo(
|
||||
() => (total: number, range: [number, number]) => `${range[0]}-${range[1]} of ${total} groups`,
|
||||
[]
|
||||
);
|
||||
// Handle query errors
|
||||
useEffect(() => {
|
||||
if (projectsError) {
|
||||
console.error('Projects query error:', projectsError);
|
||||
setErrorMessage('Failed to load projects. Please try again.');
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [projectsError]);
|
||||
|
||||
const handleTableChange = useCallback(
|
||||
(
|
||||
@@ -262,135 +385,108 @@ const ProjectList: React.FC = () => {
|
||||
filters: Record<string, FilterValue | null>,
|
||||
sorter: SorterResult<IProjectViewModel> | SorterResult<IProjectViewModel>[]
|
||||
) => {
|
||||
const newParams: Partial<typeof requestParams> = {};
|
||||
// Batch all parameter updates to reduce re-renders
|
||||
const updates: Partial<typeof requestParams> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
// Handle status filters
|
||||
if (filters?.status_id !== filteredInfo.status_id) {
|
||||
if (!filters?.status_id) {
|
||||
newParams.statuses = null;
|
||||
updates.statuses = null;
|
||||
dispatch(setFilteredStatuses([]));
|
||||
} else {
|
||||
newParams.statuses = filters.status_id.join(' ');
|
||||
updates.statuses = filters.status_id.join(' ');
|
||||
}
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Handle category filters
|
||||
if (filters?.category_id !== filteredInfo.category_id) {
|
||||
if (!filters?.category_id) {
|
||||
newParams.categories = null;
|
||||
updates.categories = null;
|
||||
dispatch(setFilteredCategories([]));
|
||||
} else {
|
||||
newParams.categories = filters.category_id.join(' ');
|
||||
updates.categories = filters.category_id.join(' ');
|
||||
}
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Handle sorting
|
||||
const newOrder = Array.isArray(sorter) ? sorter[0].order : sorter.order;
|
||||
const newField = (Array.isArray(sorter) ? sorter[0].columnKey : sorter.columnKey) as string;
|
||||
|
||||
if (newOrder && newField) {
|
||||
newParams.order = newOrder ?? 'ascend';
|
||||
newParams.field = newField ?? 'name';
|
||||
setSortingValues(newParams.field, newParams.order);
|
||||
if (newOrder && newField && (newOrder !== requestParams.order || newField !== requestParams.field)) {
|
||||
updates.order = newOrder ?? 'ascend';
|
||||
updates.field = newField ?? 'name';
|
||||
setSortingValues(updates.field, updates.order);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
newParams.index = newPagination.current || 1;
|
||||
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
|
||||
// Handle pagination
|
||||
if (newPagination.current !== requestParams.index || newPagination.pageSize !== requestParams.size) {
|
||||
updates.index = newPagination.current || 1;
|
||||
updates.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
dispatch(setRequestParams(newParams));
|
||||
// Only dispatch if there are actual changes
|
||||
if (hasChanges) {
|
||||
dispatch(setRequestParams(updates));
|
||||
|
||||
// Also update grouped request params to keep them in sync
|
||||
dispatch(
|
||||
setGroupedRequestParams({
|
||||
...groupedRequestParams,
|
||||
statuses: newParams.statuses,
|
||||
categories: newParams.categories,
|
||||
order: newParams.order,
|
||||
field: newParams.field,
|
||||
index: newParams.index,
|
||||
size: newParams.size,
|
||||
...updates,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredInfo(filters);
|
||||
},
|
||||
[dispatch, setSortingValues, groupedRequestParams]
|
||||
[dispatch, setSortingValues, groupedRequestParams, filteredInfo, requestParams]
|
||||
);
|
||||
|
||||
// Optimized grouped table change handler
|
||||
const handleGroupedTableChange = useCallback(
|
||||
(newPagination: TablePaginationConfig) => {
|
||||
const newParams: Partial<typeof groupedRequestParams> = {
|
||||
index: newPagination.current || 1,
|
||||
size: newPagination.pageSize || DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
|
||||
// Only update if values actually changed
|
||||
if (newParams.index !== groupedRequestParams.index || newParams.size !== groupedRequestParams.size) {
|
||||
dispatch(setGroupedRequestParams(newParams));
|
||||
}
|
||||
},
|
||||
[dispatch, groupedRequestParams]
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
trackMixpanelEvent(evt_projects_refresh_click);
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
refetchProjects();
|
||||
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||
dispatch(fetchGroupedProjects(groupedRequestParams));
|
||||
}
|
||||
}, [trackMixpanelEvent, refetchProjects, viewMode, groupBy, dispatch, groupedRequestParams]);
|
||||
|
||||
// Optimized segment change handler with better state management
|
||||
const handleSegmentChange = useCallback(
|
||||
(value: IProjectFilter) => {
|
||||
const newFilterIndex = filters.indexOf(value);
|
||||
setFilterIndex(newFilterIndex);
|
||||
|
||||
// Update both request params for consistency
|
||||
dispatch(setRequestParams({ filter: newFilterIndex }));
|
||||
dispatch(
|
||||
setGroupedRequestParams({
|
||||
...groupedRequestParams,
|
||||
filter: newFilterIndex,
|
||||
index: 1, // Reset to first page when changing filter
|
||||
})
|
||||
);
|
||||
// Batch updates to reduce re-renders
|
||||
const baseUpdates = { filter: newFilterIndex, index: 1 };
|
||||
|
||||
// Refresh data based on current view mode
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
refetchProjects();
|
||||
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||
dispatch(
|
||||
fetchGroupedProjects({
|
||||
dispatch(setRequestParams(baseUpdates));
|
||||
dispatch(setGroupedRequestParams({
|
||||
...groupedRequestParams,
|
||||
filter: newFilterIndex,
|
||||
index: 1,
|
||||
})
|
||||
);
|
||||
...baseUpdates,
|
||||
}));
|
||||
|
||||
// Only trigger data fetch for group view (list view will auto-refetch via query)
|
||||
if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||
dispatch(fetchGroupedProjects({
|
||||
...groupedRequestParams,
|
||||
...baseUpdates,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[filters, setFilterIndex, dispatch, refetchProjects, viewMode, groupBy, groupedRequestParams]
|
||||
);
|
||||
|
||||
// Debounced search for grouped projects
|
||||
const debouncedGroupedSearch = useCallback(
|
||||
debounce((params: typeof groupedRequestParams) => {
|
||||
if (groupBy) {
|
||||
dispatch(fetchGroupedProjects(params));
|
||||
}
|
||||
}, 300),
|
||||
[dispatch, groupBy]
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const searchValue = e.target.value;
|
||||
trackMixpanelEvent(evt_projects_search);
|
||||
|
||||
// Update both request params for consistency
|
||||
dispatch(setRequestParams({ search: searchValue, index: 1 }));
|
||||
|
||||
if (viewMode === ProjectViewType.GROUP) {
|
||||
const newGroupedParams = {
|
||||
...groupedRequestParams,
|
||||
search: searchValue,
|
||||
index: 1,
|
||||
};
|
||||
dispatch(setGroupedRequestParams(newGroupedParams));
|
||||
|
||||
// Trigger debounced search in group mode
|
||||
debouncedGroupedSearch(newGroupedParams);
|
||||
}
|
||||
},
|
||||
[dispatch, trackMixpanelEvent, viewMode, groupedRequestParams, debouncedGroupedSearch]
|
||||
[filters, setFilterIndex, dispatch, groupedRequestParams, viewMode, groupBy]
|
||||
);
|
||||
|
||||
const handleViewToggle = useCallback(
|
||||
@@ -557,20 +653,19 @@ const ProjectList: React.FC = () => {
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
setIsLoading(loadingProjects || isFetchingProjects);
|
||||
} else {
|
||||
setIsLoading(groupedProjects.loading);
|
||||
}
|
||||
}, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading]);
|
||||
|
||||
// Optimize useEffect hooks to reduce unnecessary API calls
|
||||
useEffect(() => {
|
||||
const filterIndex = getFilterIndex();
|
||||
dispatch(setRequestParams({ filter: filterIndex }));
|
||||
// Also sync with grouped request params on initial load
|
||||
dispatch(
|
||||
setGroupedRequestParams({
|
||||
const initialParams = { filter: filterIndex };
|
||||
|
||||
// Only update if values are different
|
||||
if (requestParams.filter !== filterIndex) {
|
||||
dispatch(setRequestParams(initialParams));
|
||||
}
|
||||
|
||||
// Initialize grouped request params only once
|
||||
if (!groupedRequestParams.groupBy) {
|
||||
dispatch(setGroupedRequestParams({
|
||||
filter: filterIndex,
|
||||
index: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
@@ -580,29 +675,69 @@ const ProjectList: React.FC = () => {
|
||||
groupBy: '',
|
||||
statuses: null,
|
||||
categories: null,
|
||||
})
|
||||
);
|
||||
}, [dispatch, getFilterIndex]);
|
||||
}));
|
||||
}
|
||||
}, [dispatch, getFilterIndex]); // Remove requestParams and groupedRequestParams from deps to avoid loops
|
||||
|
||||
// Separate effect for tracking page visits - only run once
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_projects_page_visit);
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
refetchProjects();
|
||||
}
|
||||
}, [requestParams, refetchProjects, trackMixpanelEvent, viewMode]);
|
||||
}, [trackMixpanelEvent]);
|
||||
|
||||
// Separate useEffect for grouped projects
|
||||
// Optimized effect for grouped projects - only fetch when necessary
|
||||
useEffect(() => {
|
||||
if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||
if (viewMode === ProjectViewType.GROUP && groupBy && groupedRequestParams.groupBy) {
|
||||
dispatch(fetchGroupedProjects(groupedRequestParams));
|
||||
}
|
||||
}, [dispatch, viewMode, groupBy, groupedRequestParams]);
|
||||
|
||||
// Optimize lookups loading - only fetch once
|
||||
useEffect(() => {
|
||||
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
|
||||
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
|
||||
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
|
||||
}, [dispatch, projectStatuses.length, projectCategories.length, projectHealths.length]);
|
||||
const loadLookups = async () => {
|
||||
const promises = [];
|
||||
|
||||
if (projectStatuses.length === 0) {
|
||||
promises.push(dispatch(fetchProjectStatuses()));
|
||||
}
|
||||
if (projectCategories.length === 0) {
|
||||
promises.push(dispatch(fetchProjectCategories()));
|
||||
}
|
||||
if (projectHealths.length === 0) {
|
||||
promises.push(dispatch(fetchProjectHealth()));
|
||||
}
|
||||
|
||||
// Load all lookups in parallel
|
||||
if (promises.length > 0) {
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
};
|
||||
|
||||
loadLookups();
|
||||
}, [dispatch]); // Remove length dependencies to avoid re-runs
|
||||
|
||||
// Sync search input value with Redux state
|
||||
useEffect(() => {
|
||||
const currentSearch = viewMode === ProjectViewType.LIST ? requestParams.search : groupedRequestParams.search;
|
||||
if (searchValue !== currentSearch) {
|
||||
setSearchValue(currentSearch || '');
|
||||
}
|
||||
}, [requestParams.search, groupedRequestParams.search, viewMode, searchValue]);
|
||||
|
||||
// Optimize loading state management
|
||||
useEffect(() => {
|
||||
let newLoadingState = false;
|
||||
|
||||
if (viewMode === ProjectViewType.LIST) {
|
||||
newLoadingState = loadingProjects || isFetchingProjects;
|
||||
} else {
|
||||
newLoadingState = groupedProjects.loading;
|
||||
}
|
||||
|
||||
// Only update if loading state actually changed
|
||||
if (isLoading !== newLoadingState) {
|
||||
setIsLoading(newLoadingState);
|
||||
}
|
||||
}, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading, isLoading]);
|
||||
|
||||
return (
|
||||
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
||||
@@ -638,9 +773,14 @@ const ProjectList: React.FC = () => {
|
||||
placeholder={t('placeholder')}
|
||||
suffix={<SearchOutlined />}
|
||||
type="text"
|
||||
value={requestParams.search}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
aria-label="Search projects"
|
||||
allowClear
|
||||
onClear={() => {
|
||||
setSearchValue('');
|
||||
debouncedSearch('');
|
||||
}}
|
||||
/>
|
||||
{isOwnerOrAdmin && <CreateProjectButton />}
|
||||
</Flex>
|
||||
@@ -657,7 +797,7 @@ const ProjectList: React.FC = () => {
|
||||
size="small"
|
||||
onChange={handleTableChange}
|
||||
pagination={paginationConfig}
|
||||
locale={{ emptyText }}
|
||||
locale={{ emptyText: emptyContent }}
|
||||
onRow={record => ({
|
||||
onClick: () => navigateToProject(record.id, record.team_member_default_view),
|
||||
onMouseEnter: () => handleProjectHover(record.id),
|
||||
|
||||
@@ -1,582 +0,0 @@
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
|
||||
import { Flex, Skeleton } from 'antd';
|
||||
import BoardSectionCardContainer from './board-section/board-section-container';
|
||||
import {
|
||||
fetchBoardTaskGroups,
|
||||
reorderTaskGroups,
|
||||
moveTaskBetweenGroups,
|
||||
IGroupBy,
|
||||
updateTaskProgress,
|
||||
} from '@features/board/board-slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
closestCenter,
|
||||
DragOverlay,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
getFirstCollision,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
UniqueIdentifier,
|
||||
} from '@dnd-kit/core';
|
||||
import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import {
|
||||
evt_project_board_visit,
|
||||
evt_project_task_list_drag_and_move,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
import { debounce } from 'lodash';
|
||||
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
|
||||
import { updateTaskPriority as updateBoardTaskPriority } from '@/features/board/board-slice';
|
||||
|
||||
interface DroppableContainer {
|
||||
id: UniqueIdentifier;
|
||||
data: {
|
||||
current?: {
|
||||
type?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const ProjectViewBoard = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectView } = useTabSearchParam();
|
||||
const { socket } = useSocket();
|
||||
const authService = useAuthService();
|
||||
const currentSession = authService.getCurrentSession();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const [currentTaskIndex, setCurrentTaskIndex] = useState(-1);
|
||||
// Add local loading state to immediately show skeleton
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(
|
||||
state => state.boardReducer
|
||||
);
|
||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
||||
state => state.taskStatusReducer
|
||||
);
|
||||
const [activeItem, setActiveItem] = useState<any>(null);
|
||||
|
||||
// Store the original source group ID when drag starts
|
||||
const originalSourceGroupIdRef = useRef<string | null>(null);
|
||||
const lastOverId = useRef<UniqueIdentifier | null>(null);
|
||||
const recentlyMovedToNewContainer = useRef(false);
|
||||
const [clonedItems, setClonedItems] = useState<any>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
// Update loading state based on all loading conditions
|
||||
useEffect(() => {
|
||||
setIsLoading(loadingGroups || loadingStatusCategories);
|
||||
}, [loadingGroups, loadingStatusCategories]);
|
||||
|
||||
// Load data efficiently with async/await and Promise.all
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (projectId && groupBy && projectView === 'kanban') {
|
||||
const promises = [];
|
||||
|
||||
if (!loadingGroups) {
|
||||
promises.push(dispatch(fetchBoardTaskGroups(projectId)));
|
||||
}
|
||||
|
||||
if (!statusCategories.length) {
|
||||
promises.push(dispatch(fetchStatusesCategories()));
|
||||
}
|
||||
|
||||
// Wait for all data to load
|
||||
await Promise.all(promises);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [dispatch, projectId, groupBy, projectView, search, archived]);
|
||||
|
||||
// Create sensors with memoization to prevent unnecessary re-renders
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: {
|
||||
distance: 10,
|
||||
delay: 100,
|
||||
tolerance: 5,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const collisionDetectionStrategy = useCallback(
|
||||
(args: {
|
||||
active: { id: UniqueIdentifier; data: { current?: { type?: string } } };
|
||||
droppableContainers: DroppableContainer[];
|
||||
}) => {
|
||||
if (activeItem?.type === 'section') {
|
||||
return closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter(
|
||||
(container: DroppableContainer) => container.data.current?.type === 'section'
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Start by finding any intersecting droppable
|
||||
const pointerIntersections = pointerWithin(args);
|
||||
const intersections =
|
||||
pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args);
|
||||
let overId = getFirstCollision(intersections, 'id');
|
||||
|
||||
if (overId !== null) {
|
||||
const overContainer = args.droppableContainers.find(
|
||||
(container: DroppableContainer) => container.id === overId
|
||||
);
|
||||
|
||||
if (overContainer?.data.current?.type === 'section') {
|
||||
const containerItems = taskGroups.find(group => group.id === overId)?.tasks || [];
|
||||
|
||||
if (containerItems.length > 0) {
|
||||
overId = closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter(
|
||||
(container: DroppableContainer) =>
|
||||
container.id !== overId && container.data.current?.type === 'task'
|
||||
),
|
||||
})[0]?.id;
|
||||
}
|
||||
}
|
||||
|
||||
lastOverId.current = overId;
|
||||
return [{ id: overId }];
|
||||
}
|
||||
|
||||
if (recentlyMovedToNewContainer.current) {
|
||||
lastOverId.current = activeItem?.id;
|
||||
}
|
||||
|
||||
return lastOverId.current ? [{ id: lastOverId.current }] : [];
|
||||
},
|
||||
[activeItem, taskGroups]
|
||||
);
|
||||
|
||||
const handleTaskProgress = (data: {
|
||||
id: string;
|
||||
status: string;
|
||||
complete_ratio: number;
|
||||
completed_count: number;
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
dispatch(updateTaskProgress(data));
|
||||
};
|
||||
|
||||
// Debounced move task function to prevent rapid updates
|
||||
const debouncedMoveTask = useCallback(
|
||||
debounce(
|
||||
(taskId: string, sourceGroupId: string, targetGroupId: string, targetIndex: number) => {
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId,
|
||||
sourceGroupId,
|
||||
targetGroupId,
|
||||
targetIndex,
|
||||
})
|
||||
);
|
||||
},
|
||||
100
|
||||
),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
isDraggingRef.current = true;
|
||||
setActiveItem(active.data.current);
|
||||
setCurrentTaskIndex(active.data.current?.sortable.index);
|
||||
if (active.data.current?.type === 'task') {
|
||||
originalSourceGroupIdRef.current = active.data.current.sectionId;
|
||||
}
|
||||
setClonedItems(taskGroups);
|
||||
};
|
||||
|
||||
const findGroupForId = (id: string) => {
|
||||
// If id is a sectionId
|
||||
if (taskGroups.some(group => group.id === id)) return id;
|
||||
// If id is a taskId, find the group containing it
|
||||
const group = taskGroups.find(g => g.tasks.some(t => t.id === id));
|
||||
return group?.id;
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragOverEvent) => {
|
||||
try {
|
||||
if (!isDraggingRef.current) return;
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
// Get the ids
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
// Find the group (section) for each
|
||||
const activeGroupId = findGroupForId(activeId as string);
|
||||
const overGroupId = findGroupForId(overId as string);
|
||||
|
||||
// Only move if both groups exist and are different, and the active is a task
|
||||
if (activeGroupId && overGroupId && active.data.current?.type === 'task') {
|
||||
// Find the target index in the over group
|
||||
const targetGroup = taskGroups.find(g => g.id === overGroupId);
|
||||
let targetIndex = 0;
|
||||
if (targetGroup) {
|
||||
// If over is a task, insert before it; if over is a section, append to end
|
||||
if (over.data.current?.type === 'task') {
|
||||
targetIndex = targetGroup.tasks.findIndex(t => t.id === overId);
|
||||
if (targetIndex === -1) targetIndex = targetGroup.tasks.length;
|
||||
} else {
|
||||
targetIndex = targetGroup.tasks.length;
|
||||
}
|
||||
}
|
||||
// Use debounced move task to prevent rapid updates
|
||||
debouncedMoveTask(activeId as string, activeGroupId, overGroupId, targetIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('handleDragOver error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePriorityChange = (taskId: string, priorityId: string) => {
|
||||
if (!taskId || !priorityId || !socket) return;
|
||||
|
||||
const payload = {
|
||||
task_id: taskId,
|
||||
priority_id: priorityId,
|
||||
team_id: currentSession?.team_id,
|
||||
};
|
||||
|
||||
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify(payload));
|
||||
socket.once(
|
||||
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
|
||||
(data: ITaskListPriorityChangeResponse) => {
|
||||
dispatch(updateBoardTaskPriority(data));
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
isDraggingRef.current = false;
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || !projectId) {
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null;
|
||||
setClonedItems(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const isActiveTask = active.data.current?.type === 'task';
|
||||
const isActiveSection = active.data.current?.type === 'section';
|
||||
|
||||
// Handle task dragging between columns
|
||||
if (isActiveTask) {
|
||||
const task = active.data.current?.task;
|
||||
|
||||
// Use the original source group ID from ref instead of the potentially modified one
|
||||
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
|
||||
|
||||
// Fix: Ensure we correctly identify the target group ID
|
||||
let targetGroupId;
|
||||
if (over.data.current?.type === 'task') {
|
||||
// If dropping on a task, get its section ID
|
||||
targetGroupId = over.data.current?.sectionId;
|
||||
} else if (over.data.current?.type === 'section') {
|
||||
// If dropping directly on a section
|
||||
targetGroupId = over.id;
|
||||
} else {
|
||||
// Fallback to the over ID if type is not specified
|
||||
targetGroupId = over.id;
|
||||
}
|
||||
|
||||
// Find source and target groups
|
||||
const sourceGroup = taskGroups.find(group => group.id === sourceGroupId);
|
||||
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
|
||||
|
||||
if (!sourceGroup || !targetGroup || !task) {
|
||||
logger.error('Could not find source or target group, or task is undefined');
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetGroupId !== sourceGroupId) {
|
||||
const canContinue = await checkTaskDependencyStatus(task.id, targetGroupId);
|
||||
if (!canContinue) {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId: task.id,
|
||||
sourceGroupId: targetGroupId, // Current group (where it was moved optimistically)
|
||||
targetGroupId: sourceGroupId, // Move it back to the original source group
|
||||
targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end
|
||||
})
|
||||
);
|
||||
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find indices
|
||||
let fromIndex = sourceGroup.tasks.findIndex(t => t.id === task.id);
|
||||
// Handle case where task is not found in source group (might have been moved already in UI)
|
||||
if (fromIndex === -1) {
|
||||
logger.info('Task not found in source group. Using task sort_order from task object.');
|
||||
|
||||
// Use the sort_order from the task object itself
|
||||
const fromSortOrder = task.sort_order;
|
||||
|
||||
// Calculate target index and position
|
||||
let toIndex = -1;
|
||||
if (over.data.current?.type === 'task') {
|
||||
const overTaskId = over.data.current?.task.id;
|
||||
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
|
||||
} else {
|
||||
// If dropping on a section, append to the end
|
||||
toIndex = targetGroup.tasks.length;
|
||||
}
|
||||
|
||||
// Calculate toPos similar to Angular implementation
|
||||
const toPos =
|
||||
targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
|
||||
// Prepare socket event payload
|
||||
const body = {
|
||||
project_id: projectId,
|
||||
from_index: fromSortOrder,
|
||||
to_index: toPos,
|
||||
to_last_index: !toPos,
|
||||
from_group: sourceGroupId,
|
||||
to_group: targetGroupId,
|
||||
group_by: groupBy || 'status',
|
||||
task,
|
||||
team_id: currentSession?.team_id,
|
||||
};
|
||||
|
||||
// Emit socket event
|
||||
if (socket) {
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||
|
||||
// Set up listener for task progress update
|
||||
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
|
||||
if (task.is_sub_task) {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
} else {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle priority change if groupBy is priority
|
||||
if (groupBy === IGroupBy.PRIORITY) {
|
||||
handlePriorityChange(task.id, targetGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
// Track analytics event
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate target index and position
|
||||
let toIndex = -1;
|
||||
if (over.data.current?.type === 'task') {
|
||||
const overTaskId = over.data.current?.task.id;
|
||||
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
|
||||
} else {
|
||||
// If dropping on a section, append to the end
|
||||
toIndex = targetGroup.tasks.length;
|
||||
}
|
||||
|
||||
// Calculate toPos similar to Angular implementation
|
||||
const toPos =
|
||||
targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
// Prepare socket event payload
|
||||
const body = {
|
||||
project_id: projectId,
|
||||
from_index: sourceGroup.tasks[fromIndex].sort_order,
|
||||
to_index: toPos,
|
||||
to_last_index: !toPos,
|
||||
from_group: sourceGroupId, // Use the direct IDs instead of group objects
|
||||
to_group: targetGroupId, // Use the direct IDs instead of group objects
|
||||
group_by: groupBy || 'status', // Use the current groupBy value
|
||||
task,
|
||||
team_id: currentSession?.team_id,
|
||||
};
|
||||
// Emit socket event
|
||||
if (socket) {
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||
|
||||
// Set up listener for task progress update
|
||||
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
|
||||
if (task.is_sub_task) {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
} else {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Track analytics event
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
}
|
||||
// Handle column reordering
|
||||
else if (isActiveSection) {
|
||||
// Don't allow reordering if groupBy is phases
|
||||
if (groupBy === IGroupBy.PHASE) {
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionId = active.id;
|
||||
const fromIndex = taskGroups.findIndex(group => group.id === sectionId);
|
||||
const toIndex = taskGroups.findIndex(group => group.id === over.id);
|
||||
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
// Create a new array with the reordered groups
|
||||
const reorderedGroups = [...taskGroups];
|
||||
const [movedGroup] = reorderedGroups.splice(fromIndex, 1);
|
||||
reorderedGroups.splice(toIndex, 0, movedGroup);
|
||||
|
||||
// Dispatch action to reorder columns with the new array
|
||||
dispatch(reorderTaskGroups(reorderedGroups));
|
||||
|
||||
// Prepare column order for API
|
||||
const columnOrder = reorderedGroups.map(group => group.id);
|
||||
|
||||
// Call API to update status order
|
||||
try {
|
||||
// Use the correct API endpoint based on the Angular code
|
||||
const requestBody: ITaskStatusCreateRequest = {
|
||||
status_order: columnOrder,
|
||||
};
|
||||
|
||||
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
|
||||
if (!response.done) {
|
||||
const revertedGroups = [...reorderedGroups];
|
||||
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
||||
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
||||
dispatch(reorderTaskGroups(revertedGroups));
|
||||
alertService.error('Failed to update column order', 'Please try again');
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert the change if API call fails
|
||||
const revertedGroups = [...reorderedGroups];
|
||||
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
||||
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
||||
dispatch(reorderTaskGroups(revertedGroups));
|
||||
alertService.error('Failed to update column order', 'Please try again');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
};
|
||||
|
||||
const handleDragCancel = () => {
|
||||
isDraggingRef.current = false;
|
||||
if (clonedItems) {
|
||||
dispatch(reorderTaskGroups(clonedItems));
|
||||
}
|
||||
setActiveItem(null);
|
||||
setClonedItems(null);
|
||||
originalSourceGroupIdRef.current = null;
|
||||
};
|
||||
|
||||
// Reset the recently moved flag after animation frame
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
recentlyMovedToNewContainer.current = false;
|
||||
});
|
||||
}, [taskGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket?.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
// Track analytics event on component mount
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_project_board_visit);
|
||||
}, []);
|
||||
|
||||
// Cleanup debounced function on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedMoveTask.cancel();
|
||||
};
|
||||
}, [debouncedMoveTask]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16}>
|
||||
<TaskListFilters position={'board'} />
|
||||
<Skeleton active loading={isLoading} className="mt-4 p-4">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<BoardSectionCardContainer
|
||||
datasource={taskGroups}
|
||||
group={groupBy as 'status' | 'priority' | 'phases'}
|
||||
/>
|
||||
<DragOverlay>
|
||||
{activeItem?.type === 'task' && (
|
||||
<BoardViewTaskCard task={activeItem.task} sectionId={activeItem.sectionId} />
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewBoard;
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import TaskListBoard from '@/components/task-management/task-list-board';
|
||||
|
||||
const ProjectViewEnhancedTasks: React.FC = () => {
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
if (!project?.id) {
|
||||
return <div className="p-4 text-center text-gray-500">Project not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="project-view-enhanced-tasks">
|
||||
<TaskListBoard projectId={project.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewEnhancedTasks;
|
||||
@@ -181,9 +181,24 @@ const ProjectViewHeader = memo(() => {
|
||||
// Memoized settings handler
|
||||
const handleSettingsClick = useCallback(() => {
|
||||
if (selectedProject?.id) {
|
||||
console.log('Opening project drawer from project view for project:', selectedProject.id);
|
||||
|
||||
// Set project ID first
|
||||
dispatch(setProjectId(selectedProject.id));
|
||||
dispatch(fetchProjectData(selectedProject.id));
|
||||
|
||||
// Then fetch project data
|
||||
dispatch(fetchProjectData(selectedProject.id))
|
||||
.unwrap()
|
||||
.then((projectData) => {
|
||||
console.log('Project data fetched successfully from project view:', projectData);
|
||||
// Open drawer after data is fetched
|
||||
dispatch(toggleProjectDrawer());
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch project data from project view:', error);
|
||||
// Still open drawer even if fetch fails, so user can see error state
|
||||
dispatch(toggleProjectDrawer());
|
||||
});
|
||||
}
|
||||
}, [dispatch, selectedProject?.id]);
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Empty } from '@/shared/antd-imports';
|
||||
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 TaskGroupWrapperOptimized from './task-group-wrapper-optimized';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
|
||||
const ProjectViewTaskList = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectView } = useTabSearchParam();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [coreDataLoaded, setCoreDataLoaded] = useState(false);
|
||||
|
||||
// Split selectors to prevent unnecessary rerenders
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
|
||||
const loadingGroups = useAppSelector(state => state.taskReducer.loadingGroups);
|
||||
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
||||
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||
const fields = useAppSelector(state => state.taskReducer.fields);
|
||||
const search = useAppSelector(state => state.taskReducer.search);
|
||||
|
||||
const statusCategories = useAppSelector(state => state.taskStatusReducer.statusCategories);
|
||||
const loadingStatusCategories = useAppSelector(state => state.taskStatusReducer.loading);
|
||||
|
||||
const loadingPhases = useAppSelector(state => state.phaseReducer.loadingPhases);
|
||||
|
||||
// Simplified loading state - only wait for essential data
|
||||
// Remove dependency on phases and status categories for initial render
|
||||
const isLoading = useMemo(
|
||||
() => loadingGroups || !coreDataLoaded,
|
||||
[loadingGroups, coreDataLoaded]
|
||||
);
|
||||
|
||||
// Memoize the empty state check
|
||||
const isEmptyState = useMemo(
|
||||
() => taskGroups && taskGroups.length === 0 && !isLoading,
|
||||
[taskGroups, isLoading]
|
||||
);
|
||||
|
||||
// Handle view type changes
|
||||
useEffect(() => {
|
||||
if (projectView !== 'list' && projectView !== 'board') {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('tab', 'tasks-list');
|
||||
newParams.set('pinned_tab', 'tasks-list');
|
||||
setSearchParams(newParams);
|
||||
}
|
||||
}, [projectView, setSearchParams, searchParams]);
|
||||
|
||||
// Optimized parallel data fetching - don't wait for everything
|
||||
useEffect(() => {
|
||||
const fetchCoreData = async () => {
|
||||
if (!projectId || !groupBy || coreDataLoaded) return;
|
||||
|
||||
try {
|
||||
// Start all requests in parallel, but only wait for task columns
|
||||
// Other data can load in background without blocking UI
|
||||
const corePromises = [
|
||||
dispatch(fetchTaskListColumns(projectId)),
|
||||
dispatch(fetchTaskGroups(projectId)), // Start immediately
|
||||
];
|
||||
|
||||
// Background data - don't wait for these
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
dispatch(fetchStatusesCategories());
|
||||
|
||||
// Only wait for essential data
|
||||
await Promise.allSettled(corePromises);
|
||||
setCoreDataLoaded(true);
|
||||
} catch (error) {
|
||||
console.error('Error fetching core data:', error);
|
||||
setCoreDataLoaded(true); // Still mark as complete to prevent infinite loading
|
||||
}
|
||||
};
|
||||
|
||||
fetchCoreData();
|
||||
}, [projectId, groupBy, dispatch, coreDataLoaded]);
|
||||
|
||||
// Optimized task groups fetching - remove initialLoadComplete dependency
|
||||
useEffect(() => {
|
||||
const fetchTasks = async () => {
|
||||
if (!projectId || !groupBy || projectView !== 'list') return;
|
||||
|
||||
try {
|
||||
// Only refetch if filters change, not on initial load
|
||||
if (coreDataLoaded) {
|
||||
await dispatch(fetchTaskGroups(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching task groups:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Only refetch when filters change
|
||||
if (coreDataLoaded) {
|
||||
fetchTasks();
|
||||
}
|
||||
}, [projectId, groupBy, projectView, dispatch, fields, search, archived, coreDataLoaded]);
|
||||
|
||||
// Memoize the task groups to prevent unnecessary re-renders
|
||||
const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||
{/* Filters load synchronously - no suspense boundary */}
|
||||
<TaskListFilters position="list" />
|
||||
|
||||
{isEmptyState ? (
|
||||
<Empty description="No tasks group found" />
|
||||
) : (
|
||||
<Skeleton active loading={isLoading} className="mt-4 p-4">
|
||||
<TaskGroupWrapperOptimized taskGroups={memoizedTaskGroups} groupBy={groupBy} />
|
||||
</Skeleton>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewTaskList;
|
||||
@@ -1,82 +0,0 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Flex from 'antd/es/flex';
|
||||
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
||||
|
||||
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 TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||
|
||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||
|
||||
interface TaskGroupWrapperOptimizedProps {
|
||||
taskGroups: ITaskListGroup[];
|
||||
groupBy: string;
|
||||
}
|
||||
|
||||
const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOptimizedProps) => {
|
||||
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
|
||||
|
||||
// Use extracted hooks
|
||||
useTaskSocketHandlers();
|
||||
|
||||
// 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 without animations
|
||||
useEffect(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.task-row[data-is-dragging="true"] {
|
||||
opacity: 0.5 !important;
|
||||
z-index: 1000 !important;
|
||||
position: relative !important;
|
||||
}
|
||||
.task-row {
|
||||
/* Remove transitions during drag operations */
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Remove the animation cleanup since we're simplifying the approach
|
||||
|
||||
return (
|
||||
<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={null}
|
||||
/>
|
||||
))}
|
||||
|
||||
{createPortal(
|
||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||
document.body,
|
||||
'task-template-drawer'
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TaskGroupWrapperOptimized);
|
||||
Reference in New Issue
Block a user