feat(task-list): enhance TaskListV2 with scroll synchronization and custom column handling

- Added scroll synchronization between header and content sections using refs for improved user experience.
- Refactored custom column handling to prioritize UUIDs for API interactions, ensuring accurate updates and data retrieval.
- Introduced a message display for empty task groups, enhancing user feedback when no tasks are available.
- Updated rendering logic for task groups and tasks to improve performance and maintainability.
This commit is contained in:
chamiakJ
2025-07-07 04:21:09 +05:30
parent 746d38017f
commit 48c3d58f7e
4 changed files with 152 additions and 55 deletions

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useEffect, useState } from 'react'; import React, { useCallback, useMemo, useEffect, useState, useRef } from 'react';
import { GroupedVirtuoso } from 'react-virtuoso'; import { GroupedVirtuoso } from 'react-virtuoso';
import { import {
DndContext, DndContext,
@@ -100,6 +100,13 @@ const TaskListV2: React.FC = () => {
const customColumns = useAppSelector(selectCustomColumns); const customColumns = useAppSelector(selectCustomColumns);
const loadingColumns = useAppSelector(selectLoadingColumns); const loadingColumns = useAppSelector(selectLoadingColumns);
// Refs for scroll synchronization
const headerScrollRef = useRef<HTMLDivElement>(null);
const contentScrollRef = useRef<HTMLDivElement>(null);
// State hooks
const [initializedFromDatabase, setInitializedFromDatabase] = useState(false);
// Configure sensors for drag and drop // Configure sensors for drag and drop
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -161,13 +168,32 @@ const TaskListV2: React.FC = () => {
defaultWidth = 170; // Extra width for people with avatars defaultWidth = 170; // Extra width for people with avatars
} }
// Map the configuration data structure to the expected format
const customColumnObj = column.custom_column_obj || (column as any).configuration;
// Transform configuration format to custom_column_obj format if needed
let transformedColumnObj = customColumnObj;
if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) {
transformedColumnObj = {
...customColumnObj,
fieldType: customColumnObj.field_type,
numberType: customColumnObj.number_type,
labelPosition: customColumnObj.label_position,
previewValue: customColumnObj.preview_value,
firstNumericColumn: customColumnObj.first_numeric_column_key,
secondNumericColumn: customColumnObj.second_numeric_column_key,
selectionsList: customColumnObj.selections_list || customColumnObj.selectionsList || [],
labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [],
};
}
return { return {
id: column.key || column.id || 'unknown', id: column.key || column.id || 'unknown',
label: column.name || t('customColumns.customColumnHeader'), label: column.name || t('customColumns.customColumnHeader'),
width: `${(column as any).width || defaultWidth}px`, width: `${(column as any).width || defaultWidth}px`,
key: column.key || column.id || 'unknown', key: column.key || column.id || 'unknown',
custom_column: true, custom_column: true,
custom_column_obj: column.custom_column_obj || (column as any).configuration, custom_column_obj: transformedColumnObj,
isCustom: true, isCustom: true,
name: column.name, name: column.name,
uuid: column.id, uuid: column.id,
@@ -186,7 +212,6 @@ const TaskListV2: React.FC = () => {
}, [dispatch, urlProjectId]); }, [dispatch, urlProjectId]);
// Initialize field visibility from database when columns are loaded (only once) // Initialize field visibility from database when columns are loaded (only once)
const [initializedFromDatabase, setInitializedFromDatabase] = useState(false);
useEffect(() => { useEffect(() => {
if (columns.length > 0 && fields.length > 0 && !initializedFromDatabase) { if (columns.length > 0 && fields.length > 0 && !initializedFromDatabase) {
// Update local fields to match database state only on initial load // Update local fields to match database state only on initial load
@@ -274,9 +299,13 @@ const TaskListV2: React.FC = () => {
const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey); const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey);
// Use the UUID for API calls, not the key (nanoid)
// For custom columns, prioritize the uuid field over id field
const columnId = (columnData as any)?.uuid || columnData?.id || columnKey;
dispatch(setCustomColumnModalAttributes({ dispatch(setCustomColumnModalAttributes({
modalType: 'edit', modalType: 'edit',
columnId: columnKey, columnId: columnId,
columnData: columnData columnData: columnData
})); }));
dispatch(toggleCustomColumnModalOpen(true)); dispatch(toggleCustomColumnModalOpen(true));
@@ -288,6 +317,13 @@ const TaskListV2: React.FC = () => {
// The global socket handler will handle the real-time update // The global socket handler will handle the real-time update
}, []); }, []);
// Handle scroll synchronization
const handleContentScroll = useCallback(() => {
if (headerScrollRef.current && contentScrollRef.current) {
headerScrollRef.current.scrollLeft = contentScrollRef.current.scrollLeft;
}
}, []);
// Memoized values for GroupedVirtuoso // Memoized values for GroupedVirtuoso
const virtuosoGroups = useMemo(() => { const virtuosoGroups = useMemo(() => {
let currentTaskIndex = 0; let currentTaskIndex = 0;
@@ -346,6 +382,8 @@ const TaskListV2: React.FC = () => {
const isGroupCollapsed = collapsedGroups.has(group.id); const isGroupCollapsed = collapsedGroups.has(group.id);
const isGroupEmpty = group.actualCount === 0; const isGroupEmpty = group.actualCount === 0;
return ( return (
<div className={groupIndex > 0 ? 'mt-2' : ''}> <div className={groupIndex > 0 ? 'mt-2' : ''}>
<TaskGroupHeader <TaskGroupHeader
@@ -385,6 +423,8 @@ const TaskListV2: React.FC = () => {
const renderTask = useCallback( const renderTask = useCallback(
(taskIndex: number) => { (taskIndex: number) => {
const item = virtuosoItems[taskIndex]; const item = virtuosoItems[taskIndex];
if (!item || !urlProjectId) return null; if (!item || !urlProjectId) return null;
if ('isAddTaskRow' in item && item.isAddTaskRow) { if ('isAddTaskRow' in item && item.isAddTaskRow) {
@@ -458,9 +498,32 @@ const TaskListV2: React.FC = () => {
</div> </div>
), [visibleColumns, t, handleCustomColumnSettings]); ), [visibleColumns, t, handleCustomColumnSettings]);
// Loading and error states // Loading and error states
if (loading || loadingColumns) return <Skeleton active />; if (loading || loadingColumns) return <Skeleton active />;
if (error) return <div>Error: {error}</div>; if (error) return <div>Error: {error}</div>;
// Show message when no data
if (groups.length === 0 && !loading) {
return (
<div className="flex flex-col bg-white dark:bg-gray-900 h-full">
<div className="flex-none px-6 py-4" style={{ height: '74px', flexShrink: 0 }}>
<ImprovedTaskFilters position="list" />
</div>
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="text-lg font-medium text-gray-900 dark:text-white mb-2">
No task groups found
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Tasks will appear here when they are created or when filters are applied.
</div>
</div>
</div>
</div>
);
}
return ( return (
<DndContext <DndContext
@@ -470,7 +533,7 @@ const TaskListV2: React.FC = () => {
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="flex flex-col bg-white dark:bg-gray-900" style={{ height: '100vh', overflow: 'hidden' }}> <div className="flex flex-col bg-white dark:bg-gray-900" style={{ overflow: 'hidden' }}>
{/* Task Filters */} {/* Task Filters */}
<div className="flex-none px-6 py-4" style={{ height: '74px', flexShrink: 0 }}> <div className="flex-none px-6 py-4" style={{ height: '74px', flexShrink: 0 }}>
<ImprovedTaskFilters position="list" /> <ImprovedTaskFilters position="list" />
@@ -481,44 +544,64 @@ const TaskListV2: React.FC = () => {
{/* Table Container */} {/* Table Container */}
<div <div
className="flex-1 overflow-auto border border-gray-200 dark:border-gray-700 mx-6 rounded-lg" className="flex-1 border border-gray-200 dark:border-gray-700 mx-6 rounded-lg"
style={{ style={{
height: '600px', height: '600px',
maxHeight: '600px' maxHeight: '600px',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}} }}
> >
<div style={{ minWidth: 'max-content' }}> {/* Column Headers */}
{/* Column Headers */} <div className="flex-none">
{renderColumnHeaders()} <div
ref={headerScrollRef}
{/* Task List Content */} className="overflow-hidden"
<div className="bg-white dark:bg-gray-900"> style={{ overflowX: 'hidden', overflowY: 'hidden' }}
<SortableContext >
items={virtuosoItems {renderColumnHeaders()}
.filter(item => !('isAddTaskRow' in item) && !item.parent_task_id)
.map(item => item.id)
.filter((id): id is string => id !== undefined)}
strategy={verticalListSortingStrategy}
>
<GroupedVirtuoso
style={{ height: '550px' }}
groupCounts={virtuosoGroupCounts}
groupContent={renderGroup}
itemContent={renderTask}
components={{
List: React.forwardRef<
HTMLDivElement,
{ style?: React.CSSProperties; children?: React.ReactNode }
>(({ style, children }, ref) => (
<div ref={ref} style={style || {}} className="virtuoso-list-container bg-white dark:bg-gray-900">
{children}
</div>
)),
}}
/>
</SortableContext>
</div> </div>
</div> </div>
{/* Task List Content */}
<div
ref={contentScrollRef}
className="flex-1 bg-white dark:bg-gray-900"
style={{ overflow: 'auto' }}
onScroll={handleContentScroll}
>
<SortableContext
items={virtuosoItems
.filter(item => !('isAddTaskRow' in item) && !item.parent_task_id)
.map(item => item.id)
.filter((id): id is string => id !== undefined)}
strategy={verticalListSortingStrategy}
>
<div style={{ minWidth: 'max-content' }}>
{/* Render groups manually for debugging */}
{virtuosoGroups.map((group, groupIndex) => (
<div key={group.id}>
{/* Group Header */}
{renderGroup(groupIndex)}
{/* Group Tasks */}
{!collapsedGroups.has(group.id) && group.tasks.map((task, taskIndex) => {
const globalTaskIndex = virtuosoGroups
.slice(0, groupIndex)
.reduce((sum, g) => sum + g.count, 0) + taskIndex;
return (
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
{renderTask(globalTaskIndex)}
</div>
);
})}
</div>
))}
</div>
</SortableContext>
</div>
</div> </div>
{/* Drag Overlay */} {/* Drag Overlay */}

View File

@@ -60,6 +60,7 @@ export const CustomColumnHeader: React.FC<{
onSettingsClick: (columnId: string) => void; onSettingsClick: (columnId: string) => void;
}> = ({ column, onSettingsClick }) => { }> = ({ column, onSettingsClick }) => {
const { t } = useTranslation('task-list-table'); const { t } = useTranslation('task-list-table');
const [isHovered, setIsHovered] = useState(false);
const displayName = column.name || const displayName = column.name ||
column.label || column.label ||
@@ -68,15 +69,20 @@ export const CustomColumnHeader: React.FC<{
t('customColumns.customColumnHeader'); t('customColumns.customColumnHeader');
return ( return (
<Flex align="center" justify="space-between" className="w-full px-2"> <Flex
<span title={displayName}>{displayName}</span> align="center"
justify="space-between"
className="w-full px-2 group cursor-pointer"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={() => onSettingsClick(column.key || column.id)}
>
<span title={displayName} className="truncate flex-1 mr-2">{displayName}</span>
<Tooltip title={t('customColumns.customColumnSettings')}> <Tooltip title={t('customColumns.customColumnSettings')}>
<SettingOutlined <SettingOutlined
className="cursor-pointer hover:text-primary" className={`hover:text-blue-600 dark:hover:text-blue-400 transition-all duration-200 flex-shrink-0 ${
onClick={e => { isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
e.stopPropagation(); }`}
onSettingsClick(column.key || column.id);
}}
/> />
</Tooltip> </Tooltip>
</Flex> </Flex>

View File

@@ -9,7 +9,7 @@ import {
Select, Select,
Typography, Typography,
Popconfirm, Popconfirm,
} from 'antd'; } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SelectionTypeColumn from './selection-type-column/selection-type-column'; import SelectionTypeColumn from './selection-type-column/selection-type-column';
import NumberTypeColumn from './number-type-column/number-type-column'; import NumberTypeColumn from './number-type-column/number-type-column';
@@ -118,11 +118,13 @@ const CustomColumnModal = () => {
fullColumnData: openedColumn fullColumnData: openedColumn
}); });
// Try to get UUID from different possible locations in the column data // The customColumnId should now be the UUID passed from TaskListV2
const columnUUID = openedColumn?.id || // But also check the column data as a fallback, prioritizing uuid over id
const columnUUID = customColumnId ||
openedColumn?.uuid || openedColumn?.uuid ||
openedColumn?.custom_column_obj?.id || openedColumn?.id ||
openedColumn?.custom_column_obj?.uuid; openedColumn?.custom_column_obj?.uuid ||
openedColumn?.custom_column_obj?.id;
console.log('Extracted UUID candidates:', { console.log('Extracted UUID candidates:', {
'openedColumn?.id': openedColumn?.id, 'openedColumn?.id': openedColumn?.id,
@@ -328,7 +330,14 @@ const CustomColumnModal = () => {
} }
: null; : null;
if (updatedColumn && openedColumn?.id) { // Get the correct UUID for the update operation, prioritizing uuid over id
const updateColumnUUID = customColumnId ||
openedColumn?.uuid ||
openedColumn?.id ||
openedColumn?.custom_column_obj?.uuid ||
openedColumn?.custom_column_obj?.id;
if (updatedColumn && updateColumnUUID) {
try { try {
// Prepare the configuration object // Prepare the configuration object
const configuration = { const configuration = {
@@ -363,7 +372,7 @@ const CustomColumnModal = () => {
}; };
// Make API request to update custom column using the service // Make API request to update custom column using the service
await tasksCustomColumnsService.updateCustomColumn(openedColumn.id, { await tasksCustomColumnsService.updateCustomColumn(updateColumnUUID, {
name: value.fieldTitle, name: value.fieldTitle,
field_type: value.fieldType, field_type: value.fieldType,
width: 150, width: 150,

View File

@@ -28,13 +28,12 @@ const SelectionTypeColumn = () => {
const { const {
customColumnModalType, customColumnModalType,
customColumnId, customColumnId,
currentColumnData,
selectionsList: storeSelectionsList, selectionsList: storeSelectionsList,
} = useAppSelector(state => state.taskListCustomColumnsReducer); } = useAppSelector(state => state.taskListCustomColumnsReducer);
// Get the opened column data if in edit mode // Use the current column data passed from TaskListV2
const openedColumn = useAppSelector(state => const openedColumn = currentColumnData;
state.taskReducer.customColumns.find(col => col.key === customColumnId)
);
console.log('SelectionTypeColumn render:', { console.log('SelectionTypeColumn render:', {
customColumnModalType, customColumnModalType,