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:
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user