From dc3433a0369b6481b2fa30a420a3f28ad71c2b46 Mon Sep 17 00:00:00 2001 From: shancds Date: Mon, 30 Jun 2025 12:25:22 +0530 Subject: [PATCH 1/3] feat(enhanced-kanban): add task assignees and labels fetching with improved filter management - Implemented async thunks to fetch task assignees and labels for enhanced Kanban board. - Updated state management to store original and current data for task assignees and labels. - Enhanced filter selection actions to update both original and current data seamlessly. - Integrated filter data loader for improved user experience in task management. --- .../enhanced-kanban/EnhancedKanbanBoard.tsx | 10 + .../task-management/improved-task-filters.tsx | 251 +++++++++++------- .../enhanced-kanban/enhanced-kanban.slice.ts | 113 +++++++- 3 files changed, 277 insertions(+), 97 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx index 998713ff..28a70475 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx @@ -30,6 +30,8 @@ import { setDragState, reorderTasks, reorderGroups, + fetchEnhancedKanbanTaskAssignees, + fetchEnhancedKanbanLabels, } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import EnhancedKanbanGroup from './EnhancedKanbanGroup'; import EnhancedKanbanTaskCard from './EnhancedKanbanTaskCard'; @@ -46,6 +48,7 @@ import { IGroupBy } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import EnhancedKanbanCreateSection from './EnhancedKanbanCreateSection'; import ImprovedTaskFilters from '../task-management/improved-task-filters'; import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; +import { useFilterDataLoader } from '@/hooks/useFilterDataLoader'; // Import the TaskListFilters component const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters')); @@ -68,6 +71,10 @@ const EnhancedKanbanBoard: React.FC = ({ projectId, cl const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy); const project = useAppSelector((state: RootState) => state.projectReducer.project); const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer); + + // Load filter data + useFilterDataLoader(); + // Local state for drag overlay const [activeTask, setActiveTask] = useState(null); const [activeGroup, setActiveGroup] = useState(null); @@ -86,6 +93,9 @@ const EnhancedKanbanBoard: React.FC = ({ projectId, cl useEffect(() => { if (projectId) { dispatch(fetchEnhancedKanbanGroups(projectId) as any); + // Load filter data for enhanced kanban + dispatch(fetchEnhancedKanbanTaskAssignees(projectId) as any); + dispatch(fetchEnhancedKanbanLabels(projectId) as any); } if (!statusCategories.length) { dispatch(fetchStatusesCategories() as any); diff --git a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx index 4fe25d60..2ebce9cd 100644 --- a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx +++ b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx @@ -42,8 +42,20 @@ import { setPriorities as setKanbanPriorities, setMembers as setKanbanMembers, fetchEnhancedKanbanGroups, + setSelectedPriorities as setKanbanSelectedPriorities, + setBoardSearch as setKanbanBoardSearch, + setTaskAssigneeSelection, + setLabelSelection, } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +// Board slice imports for compatibility +import { + setBoardSearch, + setBoardPriorities, + setBoardMembers, + setBoardLabels, +} from '@/features/board/board-slice'; + // Performance constants const FILTER_DEBOUNCE_DELAY = 300; // ms const SEARCH_DEBOUNCE_DELAY = 500; // ms @@ -60,6 +72,10 @@ const selectFilterData = createSelector( (state: any) => state.taskReducer.taskAssignees, (state: any) => state.boardReducer.taskAssignees, (state: any) => state.projectReducer.project, + // Enhanced kanban data - use original data for filter options + (state: any) => state.enhancedKanbanReducer.originalTaskAssignees, + (state: any) => state.enhancedKanbanReducer.originalLabels, + (state: any) => state.enhancedKanbanReducer.priorities, ], ( priorities, @@ -69,7 +85,10 @@ const selectFilterData = createSelector( boardLabels, taskAssignees, boardAssignees, - project + project, + kanbanOriginalTaskAssignees, + kanbanOriginalLabels, + kanbanPriorities ) => ({ priorities: priorities || [], taskPriorities: taskPriorities || [], @@ -80,6 +99,10 @@ const selectFilterData = createSelector( boardAssignees: boardAssignees || [], project, selectedPriorities: taskPriorities || [], // Use taskReducer.priorities as selected priorities + // Enhanced kanban data - use original data for filter options + kanbanTaskAssignees: kanbanOriginalTaskAssignees || [], + kanbanLabels: kanbanOriginalLabels || [], + kanbanPriorities: kanbanPriorities || [], }) ); @@ -115,7 +138,7 @@ function createDebouncedFunction void>( delay: number ): T & { cancel: () => void } { let timeoutId: ReturnType | null = null; - + const debouncedFunc = ((...args: any[]) => { if (timeoutId) { clearTimeout(timeoutId); @@ -125,14 +148,14 @@ function createDebouncedFunction void>( timeoutId = null; }, delay); }) as T & { cancel: () => void }; - + debouncedFunc.cancel = () => { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } }; - + return debouncedFunc; } @@ -141,7 +164,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { const { t } = useTranslation('task-list-filters'); const [searchParams] = useSearchParams(); const { projectView } = useTabSearchParam(); - + // Use optimized selector to get all filter data at once const filterData = useAppSelector(selectFilterData); const currentGrouping = useAppSelector(selectCurrentGrouping); @@ -160,11 +183,15 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { const currentLabels = kanbanState.labels || []; const currentAssignees = kanbanState.taskAssignees || []; const groupByValue = kanbanState.groupBy || 'status'; + + // Get priorities from the project or use empty array as fallback + const projectPriorities = (kanbanProject as any)?.priorities || []; + return [ { id: 'priority', label: 'Priority', - options: (kanbanProject?.priorities || []).map((p: any) => ({ + options: filterData.priorities.map((p: any) => ({ value: p.id, label: p.name, color: p.color_code, @@ -181,7 +208,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { multiSelect: true, searchable: true, selectedValues: currentAssignees.filter((m: any) => m.selected && m.id).map((m: any) => m.id || ''), - options: currentAssignees.map((assignee: any) => ({ + options: filterData.kanbanTaskAssignees.map((assignee: any) => ({ id: assignee.id || '', label: assignee.name || '', value: assignee.id || '', @@ -196,7 +223,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { multiSelect: true, searchable: true, selectedValues: currentLabels.filter((l: any) => l.selected && l.id).map((l: any) => l.id || ''), - options: currentLabels.map((label: any) => ({ + options: filterData.kanbanLabels.map((label: any) => ({ id: label.id || '', label: label.name || '', value: label.id || '', @@ -214,7 +241,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => { options: [ { id: 'status', label: t('statusText'), value: 'status' }, { id: 'priority', label: t('priorityText'), value: 'priority' }, - { id: 'phase', label: kanbanProject?.phase_label || t('phaseText'), value: 'phase' }, + { id: 'phase', label: (kanbanProject as any)?.phase_label || t('phaseText'), value: 'phase' }, ], }, ]; @@ -380,8 +407,8 @@ const FilterDropdown: React.FC<{ {selectedCount} )} - @@ -397,11 +424,10 @@ const FilterDropdown: React.FC<{ value={searchTerm} onChange={e => setSearchTerm(e.target.value)} placeholder={`Search ${section.label.toLowerCase()}...`} - className={`w-full pl-8 pr-2 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-150 ${ - isDarkMode - ? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600' - : 'bg-white text-gray-900 placeholder-gray-400 border-gray-300' - }`} + className={`w-full pl-8 pr-2 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-150 ${isDarkMode + ? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600' + : 'bg-white text-gray-900 placeholder-gray-400 border-gray-300' + }`} /> @@ -417,7 +443,7 @@ const FilterDropdown: React.FC<{
{filteredOptions.map((option) => { const isSelected = section.selectedValues.includes(option.value); - + return ( @@ -653,7 +677,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
{sortedFields.map((field) => { const isSelected = field.visible; - + return ( @@ -1041,11 +1100,10 @@ const ImprovedTaskFilters: React.FC = ({ type="checkbox" checked={showArchived} onChange={toggleArchived} - className={`w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500 transition-colors duration-150 ${ - isDarkMode - ? 'border-gray-600 bg-gray-700 focus:ring-offset-gray-800' - : 'border-gray-300 bg-white focus:ring-offset-white' - }`} + className={`w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500 transition-colors duration-150 ${isDarkMode + ? 'border-gray-600 bg-gray-700 focus:ring-offset-gray-800' + : 'border-gray-300 bg-white focus:ring-offset-white' + }`} /> Show archived @@ -1072,23 +1130,27 @@ const ImprovedTaskFilters: React.FC = ({ if (projectId) { // Cancel pending search and immediately clear debouncedSearchChangeRef.current?.cancel(); - if (projectView === 'list') { - dispatch(setSearch('')); + if (position === 'board') { + dispatch(setKanbanSearch('')); + dispatch(fetchEnhancedKanbanGroups(projectId)); } else { - dispatch(setBoardSearch('')); + if (projectView === 'list') { + dispatch(setSearch('')); + } else { + dispatch(setKanbanBoardSearch('')); + } + dispatch(fetchTasksV3(projectId)); } - dispatch(fetchTasksV3(projectId)); } }} - className={`ml-1 rounded-full p-0.5 transition-colors duration-150 ${ - isDarkMode ? 'hover:bg-blue-800' : 'hover:bg-blue-200' - }`} + className={`ml-1 rounded-full p-0.5 transition-colors duration-150 ${isDarkMode ? 'hover:bg-blue-800' : 'hover:bg-blue-200' + }`} >
)} - + {filterSectionsData .filter(section => section.id !== 'groupBy') // <-- skip groupBy .flatMap((section) => @@ -1102,7 +1164,7 @@ const ImprovedTaskFilters: React.FC = ({ className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded-full ${themeClasses.pillBg} ${themeClasses.pillText}`} > {option.color && ( -
@@ -1111,19 +1173,18 @@ const ImprovedTaskFilters: React.FC = ({ diff --git a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts index eca3f60e..93d78e7e 100644 --- a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts +++ b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts @@ -13,6 +13,7 @@ import { ITaskStatusViewModel } from '@/types/tasks/taskStatusGetResponse.types' import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types'; import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types'; import { ITaskLabelFilter } from '@/types/tasks/taskLabel.types'; +import { labelsApiService } from '@/api/taskAttributes/labels/labels.api.service'; export enum IGroupBy { STATUS = 'status', @@ -55,7 +56,11 @@ interface EnhancedKanbanState { loadingGroups: boolean; error: string | null; - // Filters + // Filters - Original data (should not be filtered) + originalTaskAssignees: ITaskListMemberFilter[]; + originalLabels: ITaskLabelFilter[]; + + // Filters - Current filtered data taskAssignees: ITaskListMemberFilter[]; loadingAssignees: boolean; statuses: ITaskStatusViewModel[]; @@ -102,6 +107,8 @@ const initialState: EnhancedKanbanState = { taskGroups: [], loadingGroups: false, error: null, + originalTaskAssignees: [], + originalLabels: [], taskAssignees: [], loadingAssignees: false, statuses: [], @@ -155,7 +162,6 @@ export const fetchEnhancedKanbanGroups = createAsyncThunk( try { const state = getState() as { enhancedKanbanReducer: EnhancedKanbanState }; const { enhancedKanbanReducer } = state; - const selectedMembers = enhancedKanbanReducer.taskAssignees .filter(member => member.selected) .map(member => member.id) @@ -317,6 +323,40 @@ export const fetchBoardSubTasks = createAsyncThunk( } ); +// Async thunk for loading task assignees +export const fetchEnhancedKanbanTaskAssignees = createAsyncThunk( + 'enhancedKanban/fetchTaskAssignees', + async (projectId: string, { rejectWithValue }) => { + try { + const response = await tasksApiService.fetchTaskAssignees(projectId); + return response.body; + } catch (error) { + logger.error('Fetch Enhanced Kanban Task Assignees', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to fetch task assignees'); + } + } +); + +// Async thunk for loading labels +export const fetchEnhancedKanbanLabels = createAsyncThunk( + 'enhancedKanban/fetchLabels', + async (projectId: string, { rejectWithValue }) => { + try { + const response = await labelsApiService.getPriorityByProject(projectId); + return response.body; + } catch (error) { + logger.error('Fetch Enhanced Kanban Labels', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to fetch project labels'); + } + } +); + const enhancedKanbanSlice = createSlice({ name: 'enhancedKanbanReducer', initialState, @@ -407,6 +447,38 @@ const enhancedKanbanSlice = createSlice({ state.members = action.payload; }, + // New actions for filter selection that work with original data + setTaskAssigneeSelection: (state, action: PayloadAction<{ id: string; selected: boolean }>) => { + const { id, selected } = action.payload; + // Update both original and current data + state.originalTaskAssignees = state.originalTaskAssignees.map(assignee => + assignee.id === id ? { ...assignee, selected } : assignee + ); + state.taskAssignees = state.originalTaskAssignees; + }, + + setLabelSelection: (state, action: PayloadAction<{ id: string; selected: boolean }>) => { + const { id, selected } = action.payload; + // Update both original and current data + state.originalLabels = state.originalLabels.map(label => + label.id === id ? { ...label, selected } : label + ); + state.labels = state.originalLabels; + }, + + // Add missing actions for filter compatibility + setSelectedPriorities: (state, action: PayloadAction) => { + state.priorities = action.payload; + }, + + setBoardSearch: (state, action: PayloadAction) => { + state.search = action.payload; + }, + + setBoardArchived: (state, action: PayloadAction) => { + state.archived = action.payload; + }, + // Status updates updateTaskStatus: (state, action: PayloadAction) => { const { id: task_id, status_id } = action.payload; @@ -562,6 +634,38 @@ const enhancedKanbanSlice = createSlice({ // Update column order state.columnOrder = reorderedGroups.map(group => group.id); + }) + // Fetch Task Assignees + .addCase(fetchEnhancedKanbanTaskAssignees.pending, (state) => { + state.loadingAssignees = true; + state.error = null; + }) + .addCase(fetchEnhancedKanbanTaskAssignees.fulfilled, (state, action) => { + state.loadingAssignees = false; + // Store original data and current data + state.originalTaskAssignees = action.payload; + state.taskAssignees = action.payload; + }) + .addCase(fetchEnhancedKanbanTaskAssignees.rejected, (state, action) => { + state.loadingAssignees = false; + state.error = action.payload as string; + }) + // Fetch Labels + .addCase(fetchEnhancedKanbanLabels.pending, (state) => { + state.loadingLabels = true; + state.error = null; + }) + .addCase(fetchEnhancedKanbanLabels.fulfilled, (state, action) => { + state.loadingLabels = false; + // Transform labels to include selected property + const newLabels = action.payload.map((label: any) => ({ ...label, selected: false })); + // Store original data and current data + state.originalLabels = newLabels; + state.labels = newLabels; + }) + .addCase(fetchEnhancedKanbanLabels.rejected, (state, action) => { + state.loadingLabels = false; + state.error = action.payload as string; }); }, }); @@ -584,6 +688,11 @@ export const { setLabels, setPriorities, setMembers, + setTaskAssigneeSelection, + setLabelSelection, + setSelectedPriorities, + setBoardSearch, + setBoardArchived, updateTaskStatus, updateTaskPriority, deleteTask, From dee385c6dbcce183a82628fb32c5b478f83c9e04 Mon Sep 17 00:00:00 2001 From: shancds Date: Mon, 30 Jun 2025 13:42:35 +0530 Subject: [PATCH 2/3] refactor(enhanced-kanban): remove console log from handleSubTaskExpand function - Removed debugging console log from the handleSubTaskExpand function to clean up the code and improve performance. --- .../src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx index d2959707..37b9b535 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx @@ -114,7 +114,6 @@ const EnhancedKanbanTaskCard: React.FC = React.memo const handleSubTaskExpand = useCallback(() => { - console.log('handleSubTaskExpand', task, projectId); if (task && task.id && projectId) { if (task.show_sub_tasks) { // If subtasks are already loaded, just toggle visibility From 7f46b10a42edf826570c33633f950f33ace90d23 Mon Sep 17 00:00:00 2001 From: shancds Date: Mon, 30 Jun 2025 14:53:00 +0530 Subject: [PATCH 3/3] feat(enhanced-kanban): add updateEnhancedKanbanTaskStatus action for task status management - Introduced a new action to update task status within the enhanced Kanban feature, allowing for dynamic task movement between groups based on status changes. - Updated the task drawer status dropdown to utilize the new action for improved task management experience. --- .../task-drawer-status-dropdown.tsx | 3 +- .../enhanced-kanban/enhanced-kanban.slice.ts | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-status-dropdown/task-drawer-status-dropdown.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer-status-dropdown/task-drawer-status-dropdown.tsx index 73b9df06..a7460e0a 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer-status-dropdown/task-drawer-status-dropdown.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer-status-dropdown/task-drawer-status-dropdown.tsx @@ -13,6 +13,7 @@ import { ITaskStatus } from '@/types/tasks/taskStatus.types'; import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; import { Select } from 'antd'; import { useMemo } from 'react'; +import { updateEnhancedKanbanTaskStatus } from '@/features/enhanced-kanban/enhanced-kanban.slice'; interface TaskDrawerStatusDropdownProps { statuses: ITaskStatus[]; @@ -52,7 +53,7 @@ const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDr dispatch(updateTaskStatus(data)); } if (tab === 'board') { - dispatch(updateBoardTaskStatus(data)); + dispatch(updateEnhancedKanbanTaskStatus(data)); } if (data.parent_task) getTaskProgress(data.parent_task); } diff --git a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts index 93d78e7e..d7eeaa92 100644 --- a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts +++ b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts @@ -495,6 +495,42 @@ const enhancedKanbanSlice = createSlice({ }); }, + // Enhanced Kanban external status update (for use in task drawer dropdown) + updateEnhancedKanbanTaskStatus: (state, action: PayloadAction) => { + const { id: task_id, status_id } = action.payload; + let oldGroupId: string | null = null; + let foundTask: IProjectTask | null = null; + // Find the task and its group + for (const group of state.taskGroups) { + const task = group.tasks.find(t => t.id === task_id); + if (task) { + foundTask = task; + oldGroupId = group.id; + break; + } + } + if (!foundTask) return; + // If grouped by status and the group changes, move the task + if (state.groupBy === IGroupBy.STATUS && oldGroupId && oldGroupId !== status_id) { + // Remove from old group + const oldGroup = state.taskGroups.find(g => g.id === oldGroupId); + if (oldGroup) { + oldGroup.tasks = oldGroup.tasks.filter(t => t.id !== task_id); + } + // Add to new group at the top + const newGroup = state.taskGroups.find(g => g.id === status_id); + if (newGroup) { + foundTask.status_id = status_id; + newGroup.tasks.unshift(foundTask); + } + } else { + // Just update the status_id + foundTask.status_id = status_id; + } + // Update cache + state.taskCache[task_id] = foundTask; + }, + updateTaskPriority: (state, action: PayloadAction) => { const { id: task_id, priority_id } = action.payload; @@ -700,6 +736,7 @@ export const { reorderTasks, reorderGroups, addTaskToGroup, + updateEnhancedKanbanTaskStatus, } = enhancedKanbanSlice.actions; export default enhancedKanbanSlice.reducer; \ No newline at end of file