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

@@ -83,6 +83,7 @@ import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service';
import projectViewReducer from '@features/project/project-view-slice';
import taskManagementFields from '@features/task-management/taskListFields.slice';
export const store = configureStore({
middleware: getDefaultMiddleware =>
@@ -171,6 +172,7 @@ export const store = configureStore({
taskManagement: taskManagementReducer,
grouping: groupingReducer,
taskManagementSelection: selectionReducer,
taskManagementFields,
},
});

View File

@@ -0,0 +1,179 @@
import React, { useState, useEffect } from 'react';
import { Modal, Checkbox, Button, Flex, Typography, Space, Divider, message } from 'antd';
import { SettingOutlined, UpOutlined, DownOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
// Configuration interface for column visibility
interface ColumnConfig {
key: string;
label: string;
showInDropdown: boolean;
order: number;
category?: string;
}
interface ColumnConfigurationModalProps {
open: boolean;
onClose: () => void;
projectId?: string;
onSave: (config: ColumnConfig[]) => void;
currentConfig: ColumnConfig[];
}
const ColumnConfigurationModal: React.FC<ColumnConfigurationModalProps> = ({
open,
onClose,
projectId,
onSave,
currentConfig,
}) => {
const { t } = useTranslation('task-list-filters');
const [config, setConfig] = useState<ColumnConfig[]>(currentConfig);
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => {
setConfig(currentConfig);
setHasChanges(false);
}, [currentConfig, open]);
const handleToggleColumn = (key: string) => {
const newConfig = config.map(col =>
col.key === key ? { ...col, showInDropdown: !col.showInDropdown } : col
);
setConfig(newConfig);
setHasChanges(true);
};
const moveColumn = (key: string, direction: 'up' | 'down') => {
const currentIndex = config.findIndex(col => col.key === key);
if (currentIndex === -1) return;
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (newIndex < 0 || newIndex >= config.length) return;
const newConfig = [...config];
[newConfig[currentIndex], newConfig[newIndex]] = [newConfig[newIndex], newConfig[currentIndex]];
// Update order numbers
const updatedConfig = newConfig.map((item, index) => ({
...item,
order: index + 1,
}));
setConfig(updatedConfig);
setHasChanges(true);
};
const handleSave = () => {
onSave(config);
setHasChanges(false);
message.success('Column configuration saved successfully');
onClose();
};
const handleReset = () => {
setConfig(currentConfig);
setHasChanges(false);
};
const groupedColumns = config.reduce((groups, column) => {
const category = column.category || 'other';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(column);
return groups;
}, {} as Record<string, ColumnConfig[]>);
const categoryLabels: Record<string, string> = {
basic: 'Basic Information',
time: 'Time & Estimation',
dates: 'Dates',
other: 'Other',
};
return (
<Modal
title={
<Flex align="center" gap={8}>
<SettingOutlined />
<span>Configure Show Fields Dropdown</span>
</Flex>
}
open={open}
onCancel={onClose}
width={600}
footer={[
<Button key="cancel" onClick={onClose}>
Cancel
</Button>,
<Button key="reset" onClick={handleReset} disabled={!hasChanges}>
Reset
</Button>,
<Button key="save" type="primary" onClick={handleSave} disabled={!hasChanges}>
Save Configuration
</Button>,
]}
>
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary">
Configure which columns appear in the "Show Fields" dropdown and their order.
Use the up/down arrows to reorder columns.
</Typography.Text>
</div>
{Object.entries(groupedColumns).map(([category, columns]) => (
<div key={category}>
<Divider orientation="left">
<Typography.Text strong>{categoryLabels[category] || category}</Typography.Text>
</Divider>
{columns.map((column, index) => (
<div
key={column.key}
style={{
padding: '8px 12px',
margin: '4px 0',
border: '1px solid #f0f0f0',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
gap: '12px',
background: '#fafafa',
}}
>
<Checkbox
checked={column.showInDropdown}
onChange={() => handleToggleColumn(column.key)}
style={{ flex: 1 }}
>
<Typography.Text>{column.label}</Typography.Text>
</Checkbox>
<Typography.Text type="secondary" style={{ fontSize: '12px', minWidth: '60px' }}>
Order: {column.order}
</Typography.Text>
<Space>
<Button
size="small"
icon={<UpOutlined />}
onClick={() => moveColumn(column.key, 'up')}
disabled={index === 0}
/>
<Button
size="small"
icon={<DownOutlined />}
onClick={() => moveColumn(column.key, 'down')}
disabled={index === columns.length - 1}
/>
</Space>
</div>
))}
</div>
))}
</Modal>
);
};
export default ColumnConfigurationModal;

View File

@@ -1,9 +1,10 @@
import { MoreOutlined } from '@ant-design/icons';
import { MoreOutlined, SettingOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import Button from 'antd/es/button';
import Checkbox from 'antd/es/checkbox';
import Dropdown from 'antd/es/dropdown';
import Space from 'antd/es/space';
import React, { useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
@@ -14,20 +15,113 @@ import {
import { ITaskListColumn } from '@/types/tasks/taskList.types';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import ColumnConfigurationModal from './column-configuration-modal';
// Configuration interface for column visibility
interface ColumnConfig {
key: string;
label: string;
showInDropdown: boolean;
order: number;
category?: string;
}
// Default column configuration - this can be customized per project or globally
const DEFAULT_COLUMN_CONFIG: ColumnConfig[] = [
{ key: 'KEY', label: 'Key', showInDropdown: true, order: 1, category: 'basic' },
{ key: 'TASK', label: 'Task', showInDropdown: false, order: 2, category: 'basic' }, // Always visible, not in dropdown
{ key: 'DESCRIPTION', label: 'Description', showInDropdown: true, order: 3, category: 'basic' },
{ key: 'PROGRESS', label: 'Progress', showInDropdown: true, order: 4, category: 'basic' },
{ key: 'ASSIGNEES', label: 'Assignees', showInDropdown: true, order: 5, category: 'basic' },
{ key: 'LABELS', label: 'Labels', showInDropdown: true, order: 6, category: 'basic' },
{ key: 'PHASE', label: 'Phase', showInDropdown: true, order: 7, category: 'basic' },
{ key: 'STATUS', label: 'Status', showInDropdown: true, order: 8, category: 'basic' },
{ key: 'PRIORITY', label: 'Priority', showInDropdown: true, order: 9, category: 'basic' },
{ key: 'TIME_TRACKING', label: 'Time Tracking', showInDropdown: true, order: 10, category: 'time' },
{ key: 'ESTIMATION', label: 'Estimation', showInDropdown: true, order: 11, category: 'time' },
{ key: 'START_DATE', label: 'Start Date', showInDropdown: true, order: 12, category: 'dates' },
{ key: 'DUE_DATE', label: 'Due Date', showInDropdown: true, order: 13, category: 'dates' },
{ key: 'DUE_TIME', label: 'Due Time', showInDropdown: true, order: 14, category: 'dates' },
{ key: 'COMPLETED_DATE', label: 'Completed Date', showInDropdown: true, order: 15, category: 'dates' },
{ key: 'CREATED_DATE', label: 'Created Date', showInDropdown: true, order: 16, category: 'dates' },
{ key: 'LAST_UPDATED', label: 'Last Updated', showInDropdown: true, order: 17, category: 'dates' },
{ key: 'REPORTER', label: 'Reporter', showInDropdown: true, order: 18, category: 'basic' },
];
// Hook to get column configuration - can be extended to fetch from API or localStorage
const useColumnConfig = (projectId?: string): ColumnConfig[] => {
// In the future, this could fetch from:
// 1. Project-specific settings from API
// 2. User preferences from localStorage
// 3. Global settings from configuration
// 4. Team-level settings
// For now, return default configuration
// You can extend this to load from localStorage or API
const storedConfig = localStorage.getItem(`worklenz.column-config.${projectId}`);
if (storedConfig) {
try {
return JSON.parse(storedConfig);
} catch (error) {
console.warn('Failed to parse stored column config, using default');
}
}
return DEFAULT_COLUMN_CONFIG;
};
// Hook to save column configuration
const useSaveColumnConfig = () => {
return (projectId: string, config: ColumnConfig[]) => {
localStorage.setItem(`worklenz.column-config.${projectId}`, JSON.stringify(config));
};
};
const ShowFieldsFilterDropdown = () => {
const { socket, connected } = useSocket();
// localization
const { socket } = useSocket();
const { t } = useTranslation('task-list-filters');
const dispatch = useAppDispatch();
const columnList = useAppSelector(state => state.taskReducer.columns);
const { projectId, project } = useAppSelector(state => state.projectReducer);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(useColumnConfig(projectId || undefined));
const saveColumnConfig = useSaveColumnConfig();
const visibilityChangableColumnList = columnList.filter(
column => column.key !== 'selector' && column.key !== 'TASK' && column.key !== 'customColumn'
);
// Update config if projectId changes
React.useEffect(() => {
setColumnConfig(useColumnConfig(projectId || undefined));
}, [projectId, configModalOpen]);
// Filter columns based on configuration
const visibilityChangableColumnList = columnList.filter(column => {
// Always exclude selector and TASK columns from dropdown
if (column.key === 'selector' || column.key === 'TASK') {
return false;
}
// Find configuration for this column
const config = columnConfig.find(c => c.key === column.key);
// If no config found, show custom columns by default
if (!config) {
return column.custom_column;
}
// Return based on configuration
return config.showInDropdown;
});
// Sort columns based on configuration order
const sortedColumns = visibilityChangableColumnList.sort((a, b) => {
const configA = columnConfig.find(c => c.key === a.key);
const configB = columnConfig.find(c => c.key === b.key);
const orderA = configA?.order ?? 999;
const orderB = configB?.order ?? 999;
return orderA - orderB;
});
const themeMode = useAppSelector(state => state.themeReducer.mode);
@@ -51,8 +145,15 @@ const ShowFieldsFilterDropdown = () => {
}
};
const menuItems = visibilityChangableColumnList.map(col => ({
key: col.key,
const handleConfigSave = (newConfig: ColumnConfig[]) => {
setColumnConfig(newConfig);
if (projectId) saveColumnConfig(projectId, newConfig);
};
const menuItems = [
...sortedColumns.map(col => ({
key: col.key || '',
type: 'item' as const,
label: (
<Space>
<Checkbox checked={col.pinned} onChange={e => handleColumnVisibilityChange(col)}>
@@ -64,8 +165,31 @@ const ShowFieldsFilterDropdown = () => {
</Checkbox>
</Space>
),
}));
})),
{
type: 'divider' as const,
},
{
key: 'configure',
type: 'item' as const,
label: (
<Button
type="text"
icon={<SettingOutlined />}
onClick={e => {
e.stopPropagation();
setConfigModalOpen(true);
}}
style={{ width: '100%', textAlign: 'left' }}
>
Configure Fields
</Button>
),
},
];
return (
<>
<Dropdown
menu={{
items: menuItems,
@@ -75,6 +199,14 @@ const ShowFieldsFilterDropdown = () => {
>
<Button icon={<MoreOutlined />}>{t('showFieldsText')}</Button>
</Dropdown>
<ColumnConfigurationModal
open={configModalOpen}
onClose={() => setConfigModalOpen(false)}
projectId={projectId || undefined}
onSave={handleConfigSave}
currentConfig={columnConfig}
/>
</>
);
};

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,
@@ -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 }}>

View File

@@ -0,0 +1,100 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface TaskListField {
key: string;
label: string;
visible: boolean;
order: number;
}
const DEFAULT_FIELDS: TaskListField[] = [
{ key: 'KEY', label: 'Key', visible: false, order: 1 },
{ key: 'DESCRIPTION', label: 'Description', visible: false, order: 2 },
{ key: 'PROGRESS', label: 'Progress', visible: true, order: 3 },
{ key: 'ASSIGNEES', label: 'Assignees', visible: true, order: 4 },
{ key: 'LABELS', label: 'Labels', visible: true, order: 5 },
{ key: 'PHASE', label: 'Phase', visible: true, order: 6 },
{ key: 'STATUS', label: 'Status', visible: true, order: 7 },
{ key: 'PRIORITY', label: 'Priority', visible: true, order: 8 },
{ key: 'TIME_TRACKING', label: 'Time Tracking', visible: true, order: 9 },
{ key: 'ESTIMATION', label: 'Estimation', visible: false, order: 10 },
{ key: 'START_DATE', label: 'Start Date', visible: false, order: 11 },
{ key: 'DUE_DATE', label: 'Due Date', visible: true, order: 12 },
{ key: 'DUE_TIME', label: 'Due Time', visible: false, order: 13 },
{ key: 'COMPLETED_DATE', label: 'Completed Date', visible: false, order: 14 },
{ key: 'CREATED_DATE', label: 'Created Date', visible: false, order: 15 },
{ key: 'LAST_UPDATED', label: 'Last Updated', visible: false, order: 16 },
{ key: 'REPORTER', label: 'Reporter', visible: false, order: 17 },
];
const LOCAL_STORAGE_KEY = 'worklenz.taskManagement.fields';
function loadFields(): TaskListField[] {
const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
console.log('Loading fields from localStorage:', stored);
// Temporarily force defaults to debug
console.log('FORCING DEFAULT FIELDS FOR DEBUGGING');
return DEFAULT_FIELDS;
/* Commented out for debugging
if (stored) {
try {
const parsed = JSON.parse(stored);
console.log('Parsed fields from localStorage:', parsed);
return parsed;
} catch (error) {
console.warn('Failed to parse stored fields, using defaults:', error);
}
}
console.log('Using default fields:', DEFAULT_FIELDS);
return DEFAULT_FIELDS;
*/
}
function saveFields(fields: TaskListField[]) {
console.log('Saving fields to localStorage:', fields);
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(fields));
}
const initialState: TaskListField[] = loadFields();
console.log('TaskListFields slice initial state:', initialState);
const taskListFieldsSlice = createSlice({
name: 'taskManagementFields',
initialState,
reducers: {
toggleField(state, action: PayloadAction<string>) {
const field = state.find(f => f.key === action.payload);
if (field) {
field.visible = !field.visible;
saveFields(state);
}
},
setFields(state, action: PayloadAction<TaskListField[]>) {
saveFields(action.payload);
return action.payload;
},
resetFields() {
saveFields(DEFAULT_FIELDS);
return DEFAULT_FIELDS;
},
},
});
export const { toggleField, setFields, resetFields } = taskListFieldsSlice.actions;
// Utility function to force reset fields (can be called from browser console)
export const forceResetFields = () => {
localStorage.removeItem(LOCAL_STORAGE_KEY);
console.log('Cleared localStorage and reset fields to defaults');
return DEFAULT_FIELDS;
};
// Make it available globally for debugging
if (typeof window !== 'undefined') {
(window as any).forceResetTaskFields = forceResetFields;
}
export default taskListFieldsSlice.reducer;