feat(task-management): implement customizable task list fields and configuration modal

- Added a new slice for managing task list fields, allowing users to toggle visibility and order of fields in the task list.
- Introduced a ColumnConfigurationModal for users to configure which fields appear in the dropdown and their order.
- Updated ShowFieldsFilterDropdown to integrate the new configuration modal and manage field visibility.
- Enhanced task management components to utilize the new field visibility settings, improving the overall user experience and customization options.
This commit is contained in:
chamiakJ
2025-06-25 07:57:53 +05:30
parent 9a070ef5d3
commit a25fcf209a
9 changed files with 857 additions and 87 deletions

View File

@@ -5,7 +5,6 @@ import { useSearchParams } from 'react-router-dom';
import { createSelector } from '@reduxjs/toolkit';
import {
SearchOutlined,
FilterOutlined,
CloseOutlined,
DownOutlined,
TeamOutlined,
@@ -28,6 +27,8 @@ import { SocketEvents } from '@/shared/socket-events';
import { colors } from '@/styles/colors';
import SingleAvatar from '@components/common/single-avatar/single-avatar';
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
import { Dropdown, Checkbox, Button, Space } from 'antd';
import { toggleField, TaskListField } from '@/features/task-management/taskListFields.slice';
// Import Redux actions
import { fetchTasksV3, setSelectedPriorities } from '@/features/task-management/task-management.slice';
@@ -508,6 +509,113 @@ const SearchFilter: React.FC<{
);
};
const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({ themeClasses, isDarkMode }) => {
const dispatch = useDispatch();
const fieldsRaw = useSelector((state: RootState) => state.taskManagementFields);
const fields = Array.isArray(fieldsRaw) ? fieldsRaw : [];
const sortedFields = [...fields].sort((a, b) => a.order - b.order);
const [open, setOpen] = React.useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
React.useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const visibleCount = sortedFields.filter(field => field.visible).length;
return (
<div className="relative" ref={dropdownRef}>
{/* Trigger Button - matching FilterDropdown style */}
<button
onClick={() => setOpen(!open)}
className={`
inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md
border transition-all duration-200 ease-in-out
${visibleCount > 0
? (isDarkMode ? 'bg-blue-600 text-white border-blue-500' : 'bg-blue-50 text-blue-800 border-blue-300 font-semibold')
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
}
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
${themeClasses.containerBg === 'bg-gray-800' ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
`}
aria-expanded={open}
aria-haspopup="true"
>
<EyeOutlined className="w-3.5 h-3.5" />
<span>Fields</span>
{visibleCount > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-blue-500 rounded-full">
{visibleCount}
</span>
)}
<DownOutlined
className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown Panel - matching FilterDropdown style */}
{open && (
<div className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-lg border ${themeClasses.dropdownBorder}`}>
{/* Options List */}
<div className="max-h-48 overflow-y-auto">
{sortedFields.length === 0 ? (
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
No fields available
</div>
) : (
<div className="p-0.5">
{sortedFields.map((field) => {
const isSelected = field.visible;
return (
<button
key={field.key}
onClick={() => dispatch(toggleField(field.key))}
className={`
w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded
transition-colors duration-150 text-left
${isSelected
? (isDarkMode ? 'bg-blue-600 text-white' : 'bg-blue-50 text-blue-800 font-semibold')
: `${themeClasses.optionText} ${themeClasses.optionHover}`
}
`}
>
{/* Checkbox indicator - matching FilterDropdown style */}
<div className={`
flex items-center justify-center w-3.5 h-3.5 border rounded
${isSelected
? 'bg-blue-500 border-blue-500 text-white'
: 'border-gray-300 dark:border-gray-600'
}
`}>
{isSelected && <CheckOutlined className="w-2.5 h-2.5" />}
</div>
{/* Label and Count */}
<div className="flex-1 flex items-center justify-between">
<span className="truncate">{field.label}</span>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
)}
</div>
);
};
// Main Component
const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
position,
@@ -673,7 +781,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
// TODO: Implement column visibility change
}, [projectId]);
return (
return (
<div className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-3 shadow-sm ${className}`}>
<div className="flex flex-wrap items-center gap-2">
{/* Left Section - Main Filters */}
@@ -748,14 +856,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
)}
{/* Show Fields Button (for list view) */}
{position === 'list' && (
<button className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText} ${
isDarkMode ? 'focus:ring-offset-gray-800' : 'focus:ring-offset-white'
}`}>
<EyeOutlined className="w-3.5 h-3.5" />
<span>Fields</span>
</button>
)}
{position === 'list' && <FieldsDropdown themeClasses={themeClasses} isDarkMode={isDarkMode} />}
</div>
</div>
@@ -779,7 +880,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
{filterSectionsData
.filter(section => section.id !== 'groupBy') // <-- skip groupBy
.map((section) =>
.flatMap((section) =>
section.selectedValues.map((value) => {
const option = section.options.find(opt => opt.value === value);
if (!option) return null;
@@ -809,7 +910,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
</button>
</div>
);
})
}).filter(Boolean)
)}
</div>
)}

View File

@@ -9,6 +9,7 @@ import { taskManagementSelectors } from '@/features/task-management/task-managem
import { RootState } from '@/app/store';
import TaskRow from './task-row';
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
import { TaskListField } from '@/features/task-management/taskListFields.slice';
const { Text } = Typography;
@@ -39,22 +40,7 @@ const GROUP_COLORS = {
default: '#d9d9d9',
} as const;
// Column configurations for consistent layout
const FIXED_COLUMNS = [
{ key: 'drag', label: '', width: 40, fixed: true },
{ key: 'select', label: '', width: 40, fixed: true },
{ key: 'key', label: 'Key', width: 80, fixed: true },
{ key: 'task', label: 'Task', width: 475, fixed: true },
];
const SCROLLABLE_COLUMNS = [
{ key: 'progress', label: 'Progress', width: 90 },
{ key: 'members', label: 'Members', width: 150 },
{ key: 'labels', label: 'Labels', width: 200 },
{ key: 'status', label: 'Status', width: 100 },
{ key: 'priority', label: 'Priority', width: 100 },
{ key: 'timeTracking', label: 'Time Tracking', width: 120 },
];
const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
group,
@@ -82,6 +68,54 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
// Get theme from Redux store
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
// Get field visibility from taskListFields slice
const taskListFields = useSelector((state: RootState) => state.taskManagementFields) as TaskListField[];
// Define all possible columns
const allFixedColumns = [
{ key: 'drag', label: '', width: 40, alwaysVisible: true },
{ key: 'select', label: '', width: 40, alwaysVisible: true },
{ key: 'key', label: 'KEY', width: 80, fieldKey: 'KEY' },
{ key: 'task', label: 'TASK', width: 220, alwaysVisible: true },
];
const allScrollableColumns = [
{ key: 'progress', label: 'Progress', width: 90, fieldKey: 'PROGRESS' },
{ key: 'members', label: 'Members', width: 150, fieldKey: 'ASSIGNEES' },
{ key: 'labels', label: 'Labels', width: 200, fieldKey: 'LABELS' },
{ key: 'status', label: 'Status', width: 100, fieldKey: 'STATUS' },
{ key: 'priority', label: 'Priority', width: 100, fieldKey: 'PRIORITY' },
{ key: 'timeTracking', label: 'Time Tracking', width: 120, fieldKey: 'TIME_TRACKING' },
];
// Filter columns based on field visibility
const visibleFixedColumns = useMemo(() => {
return allFixedColumns.filter(col => {
// Always show columns marked as alwaysVisible
if (col.alwaysVisible) return true;
// For other columns, check field visibility
if (col.fieldKey) {
const field = taskListFields.find(f => f.key === col.fieldKey);
return field?.visible ?? false;
}
return false;
});
}, [taskListFields, allFixedColumns]);
const visibleScrollableColumns = useMemo(() => {
return allScrollableColumns.filter(col => {
// For scrollable columns, check field visibility
if (col.fieldKey) {
const field = taskListFields.find(f => f.key === col.fieldKey);
return field?.visible ?? false;
}
return false;
});
}, [taskListFields, allScrollableColumns]);
// Get tasks for this group using memoization for performance
const groupTasks = useMemo(() => {
return group.taskIds
@@ -142,7 +176,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
style={{ ...containerStyle, overflowX: 'unset' }}
>
<div className="task-group-scroll-wrapper" style={{ overflowX: 'auto', width: '100%' }}>
<div style={{ minWidth: FIXED_COLUMNS.reduce((sum, col) => sum + col.width, 0) + SCROLLABLE_COLUMNS.reduce((sum, col) => sum + col.width, 0) }}>
<div style={{ minWidth: visibleFixedColumns.reduce((sum, col) => sum + col.width, 0) + visibleScrollableColumns.reduce((sum, col) => sum + col.width, 0) }}>
{/* Group Header Row */}
<div className="task-group-header">
<div className="task-group-header-row">
@@ -172,7 +206,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
>
<div className="task-group-column-headers-row">
<div className="task-table-fixed-columns">
{FIXED_COLUMNS.map(col => (
{visibleFixedColumns.map(col => (
<div
key={col.key}
className="task-table-cell task-table-header-cell"
@@ -183,7 +217,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
))}
</div>
<div className="task-table-scrollable-columns">
{SCROLLABLE_COLUMNS.map(col => (
{visibleScrollableColumns.map(col => (
<div
key={col.key}
className="task-table-cell task-table-header-cell"
@@ -236,8 +270,8 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
index={index}
onSelect={onSelectTask}
onToggleSubtasks={onToggleSubtasks}
fixedColumns={FIXED_COLUMNS}
scrollableColumns={SCROLLABLE_COLUMNS}
fixedColumns={visibleFixedColumns}
scrollableColumns={visibleScrollableColumns}
/>
))}
</div>

View File

@@ -15,6 +15,7 @@ import { RootState } from '@/app/store';
import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, Tooltip } from '@/components';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import TaskStatusDropdown from './task-status-dropdown';
interface TaskRowProps {
task: Task;
@@ -324,7 +325,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
})}
</div>
{/* Scrollable Columns */}
<div className="scrollable-columns-row" style={{ display: 'flex', minWidth: scrollableColumns?.reduce((sum, col) => sum + col.width, 0) || 0 }}>
<div className="scrollable-columns-row overflow-visible" style={{ display: 'flex', minWidth: scrollableColumns?.reduce((sum, col) => sum + col.width, 0) || 0 }}>
{scrollableColumns?.map(col => {
switch (col.key) {
case 'progress':
@@ -392,14 +393,12 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
);
case 'status':
return (
<div key={col.key} className="flex items-center px-2 border-r" style={{ width: col.width }}>
<Tag
backgroundColor={getStatusColor(task.status)}
color="white"
className="text-xs font-medium uppercase"
>
{task.status}
</Tag>
<div key={col.key} className="flex items-center px-2 border-r overflow-visible" style={{ width: col.width }}>
<TaskStatusDropdown
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
</div>
);
case 'priority':

View File

@@ -0,0 +1,187 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { Task } from '@/types/task-management.types';
interface TaskStatusDropdownProps {
task: Task;
projectId: string;
isDarkMode?: boolean;
}
const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
task,
projectId,
isDarkMode = false
}) => {
const { socket, connected } = useSocket();
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const statusList = useAppSelector(state => state.taskStatusReducer.status);
// Find current status details
const currentStatus = useMemo(() => {
return statusList.find(status =>
status.name?.toLowerCase() === task.status?.toLowerCase() ||
status.id === task.status
);
}, [statusList, task.status]);
// Handle status change
const handleStatusChange = useCallback((statusId: string, statusName: string) => {
if (!task.id || !statusId || !connected) return;
socket?.emit(
SocketEvents.TASK_STATUS_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
status_id: statusId,
parent_task: null, // Assuming top-level tasks for now
team_id: projectId, // Using projectId as teamId
})
);
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
setIsOpen(false);
}, [task.id, connected, socket, projectId]);
// Calculate dropdown position and handle outside clicks
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (buttonRef.current && buttonRef.current.contains(event.target as Node)) {
return; // Don't close if clicking the button
}
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen && buttonRef.current) {
// Calculate position
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
});
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Get status color
const getStatusColor = useCallback((status: any) => {
if (isDarkMode) {
return status?.color_code_dark || status?.color_code || '#6b7280';
}
return status?.color_code || '#6b7280';
}, [isDarkMode]);
// Status display name
const getStatusDisplayName = useCallback((status: string) => {
return status.charAt(0).toUpperCase() + status.slice(1);
}, []);
if (!task.status) return null;
return (
<>
{/* Status Button */}
<button
ref={buttonRef}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log('Status dropdown clicked, current isOpen:', isOpen);
setIsOpen(!isOpen);
}}
className={`
inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium
transition-all duration-200 hover:opacity-80 border-0 min-w-[70px] justify-center
`}
style={{
backgroundColor: currentStatus ? getStatusColor(currentStatus) : (isDarkMode ? '#6b7280' : '#9ca3af'),
color: 'white',
}}
>
<span>{currentStatus?.name || getStatusDisplayName(task.status)}</span>
<svg
className={`w-3 h-3 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown Menu - Rendered in Portal */}
{isOpen && createPortal(
<div
ref={dropdownRef}
className={`
fixed min-w-[120px] max-w-[180px]
rounded-lg shadow-xl border z-[9999]
${isDarkMode
? 'bg-gray-800 border-gray-600'
: 'bg-white border-gray-200'
}
`}
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
zIndex: 9999
}}
>
<div className="py-1">
{statusList.map((status) => (
<button
key={status.id}
onClick={() => handleStatusChange(status.id!, status.name!)}
className={`
w-full px-3 py-2 text-left text-xs font-medium flex items-center gap-2
transition-colors duration-150 rounded-md mx-1
${isDarkMode
? 'hover:bg-gray-700 text-gray-200'
: 'hover:bg-gray-50 text-gray-900'
}
${(status.name?.toLowerCase() === task.status?.toLowerCase() || status.id === task.status)
? (isDarkMode ? 'bg-gray-700' : 'bg-gray-50')
: ''
}
`}
>
{/* Status Pill Preview */}
<div
className="px-2 py-0.5 rounded-full text-white text-xs min-w-[50px] text-center"
style={{ backgroundColor: getStatusColor(status) }}
>
{status.name}
</div>
{/* Current Status Indicator */}
{(status.name?.toLowerCase() === task.status?.toLowerCase() || status.id === task.status) && (
<div className="ml-auto">
<div className={`w-1.5 h-1.5 rounded-full ${isDarkMode ? 'bg-blue-400' : 'bg-blue-500'}`} />
</div>
)}
</button>
))}
</div>
</div>,
document.body
)}
</>
);
};
export default TaskStatusDropdown;

View File

@@ -6,6 +6,8 @@ import { taskManagementSelectors } from '@/features/task-management/task-managem
import { Task } from '@/types/task-management.types';
import TaskRow from './task-row';
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
import { RootState } from '@/app/store';
import { TaskListField } from '@/features/task-management/taskListFields.slice';
interface VirtualizedTaskListProps {
group: any;
@@ -30,6 +32,18 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
}) => {
const allTasks = useSelector(taskManagementSelectors.selectAll);
// Get field visibility from taskListFields slice
const taskListFields = useSelector((state: RootState) => state.taskManagementFields) as TaskListField[];
// Debug logging
useEffect(() => {
console.log('VirtualizedTaskList Debug:', {
taskListFields,
fieldsLength: taskListFields?.length,
fieldsState: taskListFields?.map(f => ({ key: f.key, visible: f.visible }))
});
}, [taskListFields]);
// Get tasks for this group using memoization for performance
const groupTasks = useMemo(() => {
return group.taskIds
@@ -85,21 +99,43 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
};
}, []);
// Define columns array for alignment
const columns = [
{ key: 'drag', label: '', width: 40, fixed: true },
{ key: 'select', label: '', width: 40, fixed: true },
{ key: 'key', label: 'KEY', width: 80, fixed: true },
{ key: 'task', label: 'TASK', width: 475, fixed: true },
{ key: 'progress', label: 'PROGRESS', width: 90 },
{ key: 'members', label: 'MEMBERS', width: 150 },
{ key: 'labels', label: 'LABELS', width: 200 },
{ key: 'status', label: 'STATUS', width: 100 },
{ key: 'priority', label: 'PRIORITY', width: 100 },
{ key: 'timeTracking', label: 'TIME TRACKING', width: 120 },
// Define all possible columns
const allColumns = [
{ key: 'drag', label: '', width: 40, fixed: true, alwaysVisible: true },
{ key: 'select', label: '', width: 40, fixed: true, alwaysVisible: true },
{ key: 'key', label: 'KEY', width: 80, fixed: true, fieldKey: 'KEY' },
{ key: 'task', label: 'TASK', width: 475, fixed: true, alwaysVisible: true },
{ key: 'progress', label: 'PROGRESS', width: 90, fieldKey: 'PROGRESS' },
{ key: 'members', label: 'MEMBERS', width: 150, fieldKey: 'ASSIGNEES' },
{ key: 'labels', label: 'LABELS', width: 200, fieldKey: 'LABELS' },
{ key: 'status', label: 'STATUS', width: 100, fieldKey: 'STATUS' },
{ key: 'priority', label: 'PRIORITY', width: 100, fieldKey: 'PRIORITY' },
{ key: 'timeTracking', label: 'TIME TRACKING', width: 120, fieldKey: 'TIME_TRACKING' },
];
const fixedColumns = columns.filter(col => col.fixed);
const scrollableColumns = columns.filter(col => !col.fixed);
// Filter columns based on field visibility
const visibleColumns = useMemo(() => {
const filtered = allColumns.filter(col => {
// Always show columns marked as alwaysVisible
if (col.alwaysVisible) return true;
// For other columns, check field visibility
if (col.fieldKey) {
const field = taskListFields.find(f => f.key === col.fieldKey);
const isVisible = field?.visible ?? false;
console.log(`Column ${col.key} (fieldKey: ${col.fieldKey}):`, { field, isVisible });
return isVisible;
}
return false;
});
console.log('Visible columns after filtering:', filtered.map(c => ({ key: c.key, fieldKey: c.fieldKey })));
return filtered;
}, [taskListFields, allColumns]);
const fixedColumns = visibleColumns.filter(col => col.fixed);
const scrollableColumns = visibleColumns.filter(col => !col.fixed);
const fixedWidth = fixedColumns.reduce((sum, col) => sum + col.width, 0);
const scrollableWidth = scrollableColumns.reduce((sum, col) => sum + col.width, 0);
const totalTableWidth = fixedWidth + scrollableWidth;
@@ -130,7 +166,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
/>
</div>
);
}, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]);
}, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks, fixedColumns, scrollableColumns]);
return (
<div className="virtualized-task-list" style={{ height: height }}>