feat(task-list): refine task list components and improve UI consistency

- Updated SubtaskLoadingSkeleton and TaskRow components for better spacing and visual consistency.
- Simplified TaskGroupHeader by removing unnecessary elements and enhancing the display of group names.
- Adjusted TaskListV2 to improve column rendering and added state management for field visibility synchronization with the database.
- Enhanced AddTaskRow and AddSubtaskRow components for improved user interaction and layout.
- Updated placeholder texts in CustomColumnComponents for better clarity.
This commit is contained in:
chamiakJ
2025-07-07 03:39:39 +05:30
parent 01298928c7
commit 746d38017f
9 changed files with 226 additions and 195 deletions

View File

@@ -26,7 +26,6 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
case 'title':
return (
<div style={baseStyle} className="flex items-center">
{/* Subtask indentation - tighter spacing */}
<div className="w-4" />
<div className="w-2" />
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
@@ -35,7 +34,7 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
case 'description':
return (
<div style={baseStyle} className="flex items-center px-2">
<div className="h-4 w-40 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'status':
@@ -66,7 +65,7 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
case 'progress':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-2 w-16 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse" />
<div className="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'labels':
@@ -91,7 +90,7 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
case 'estimation':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-4 w-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-4 w-8 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
case 'startDate':
@@ -132,7 +131,7 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
return (
<div className="bg-gray-50 dark:bg-gray-800/50 border-l-2 border-blue-200 dark:border-blue-700">
<div className="flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700">
{visibleColumns.map((column) => (
{visibleColumns.map((column, index) => (
<div key={column.id}>
{renderColumn(column.id, column.width)}
</div>

View File

@@ -166,8 +166,6 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
setCategoryModalVisible(true);
}, []);
// Handle category change
const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => {
e?.stopPropagation();
@@ -292,108 +290,18 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
<div className="flex items-center flex-1 ml-1">
{/* Group name and count */}
<div className="flex items-center">
{isEditingName && isOwnerOrAdmin ? (
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleNameKeyDown}
onBlur={handleNameBlur}
className="text-sm font-semibold px-2 py-1 rounded-md transition-all duration-200 focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
style={{
color: headerTextColor,
fontSize: '14px',
fontWeight: 600,
width: `${Math.max(editingName.length * 8 + 16, 80)}px`,
minWidth: '80px',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
border: `1px solid ${headerTextColor}40`,
backdropFilter: 'blur(4px)'
}}
styles={{
input: {
color: headerTextColor,
backgroundColor: 'transparent',
border: 'none',
outline: 'none',
boxShadow: 'none',
padding: '0'
}
}}
autoFocus
disabled={isRenaming}
placeholder={t('enterGroupName')}
/>
) : (
<span
className={`text-sm font-semibold ${isOwnerOrAdmin ? 'cursor-pointer hover:bg-black hover:bg-opacity-10 dark:hover:bg-white dark:hover:bg-opacity-10 rounded px-2 py-1 transition-all duration-200 hover:shadow-sm' : ''}`}
onClick={handleNameClick}
className="text-sm font-semibold"
style={{ color: headerTextColor }}
title={isOwnerOrAdmin ? t('clickToEditGroupName') : ''}
>
{group.name}
</span>
)}
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
({group.count})
</span>
</div>
{/* Three dots menu */}
<div className="flex items-center justify-center ml-2">
<Dropdown
menu={{ items: menuItems }}
trigger={['click']}
open={dropdownVisible}
onOpenChange={setDropdownVisible}
placement="bottomLeft"
>
<button
className="p-1 rounded-sm hover:bg-black hover:bg-opacity-10 transition-all duration-200 ease-out"
style={{ color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
setDropdownVisible(!dropdownVisible);
}}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</button>
</Dropdown>
</div>
</div>
{/* Change Category Modal */}
<Modal
title="Change Category"
open={categoryModalVisible}
onCancel={() => setCategoryModalVisible(false)}
footer={null}
width={400}
>
<div className="py-4">
<div className="space-y-2">
{statusCategories?.map((category) => (
<div
key={category.id}
className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
onClick={(e) => category.id && handleCategoryChange(category.id, e)}
>
<Flex align="center" gap={12}>
<Badge color={category.color_code} />
<span className="font-medium">{category.name}</span>
</Flex>
{isChangingCategory && (
<div className="text-blue-500">
<ArrowPathIcon className="h-4 w-4 animate-spin" />
</div>
)}
</div>
))}
</div>
</div>
</Modal>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useEffect } from 'react';
import React, { useCallback, useMemo, useEffect, useState } from 'react';
import { GroupedVirtuoso } from 'react-virtuoso';
import {
DndContext,
@@ -156,7 +156,7 @@ const TaskListV2: React.FC = () => {
const fieldType = column.custom_column_obj?.fieldType;
let defaultWidth = 160;
if (fieldType === 'selection') {
defaultWidth = 180; // Extra width for selection dropdowns
defaultWidth = 150; // Reduced width for selection dropdowns
} else if (fieldType === 'people') {
defaultWidth = 170; // Extra width for people with avatars
}
@@ -177,36 +177,6 @@ const TaskListV2: React.FC = () => {
return [...baseVisibleColumns, ...visibleCustomColumns];
}, [fields, columns, customColumns, t]);
// Sync local field changes with backend column configuration (debounced)
useEffect(() => {
if (!urlProjectId || columns.length === 0 || fields.length === 0) return;
const timeoutId = setTimeout(() => {
const changedFields = fields.filter(field => {
const backendColumn = columns.find(c => c.key === field.key);
if (backendColumn) {
return (backendColumn.pinned ?? false) !== field.visible;
}
return false;
});
changedFields.forEach(field => {
const backendColumn = columns.find(c => c.key === field.key);
if (backendColumn) {
dispatch(updateColumnVisibility({
projectId: urlProjectId,
item: {
...backendColumn,
pinned: field.visible
}
}));
}
});
}, 500);
return () => clearTimeout(timeoutId);
}, [fields, columns, urlProjectId, dispatch]);
// Effects
useEffect(() => {
if (urlProjectId) {
@@ -215,6 +185,38 @@ const TaskListV2: React.FC = () => {
}
}, [dispatch, urlProjectId]);
// Initialize field visibility from database when columns are loaded (only once)
const [initializedFromDatabase, setInitializedFromDatabase] = useState(false);
useEffect(() => {
if (columns.length > 0 && fields.length > 0 && !initializedFromDatabase) {
// Update local fields to match database state only on initial load
import('@/features/task-management/taskListFields.slice').then(({ setFields }) => {
// Create updated fields based on database column state
const updatedFields = fields.map(field => {
const backendColumn = columns.find(c => c.key === field.key);
if (backendColumn) {
return {
...field,
visible: backendColumn.pinned ?? field.visible
};
}
return field;
});
// Only update if there are actual changes
const hasChanges = updatedFields.some((field, index) =>
field.visible !== fields[index].visible
);
if (hasChanges) {
dispatch(setFields(updatedFields));
}
setInitializedFromDatabase(true);
});
}
}, [columns, fields, dispatch, initializedFromDatabase]);
// Event handlers
const handleTaskSelect = useCallback(
(taskId: string, event: React.MouseEvent) => {
@@ -360,7 +362,7 @@ const TaskListV2: React.FC = () => {
{isGroupEmpty && !isGroupCollapsed && (
<div className="relative w-full">
<div className="flex items-center min-w-max px-1 py-3">
{visibleColumns.map((column) => (
{visibleColumns.map((column, index) => (
<div
key={`empty-${column.id}`}
style={{ width: column.width, flexShrink: 0 }}
@@ -413,8 +415,8 @@ const TaskListV2: React.FC = () => {
// Render column headers
const renderColumnHeaders = useCallback(() => (
<div className="sticky top-0 z-30 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center px-1 py-3 w-full" style={{ minWidth: 'max-content', height: '44px' }}>
{visibleColumns.map(column => {
<div className="flex items-center px-3 py-3 w-full" style={{ minWidth: 'max-content', height: '44px' }}>
{visibleColumns.map((column, index) => {
const columnStyle: ColumnStyle = {
width: column.width,
flexShrink: 0,
@@ -470,13 +472,16 @@ const TaskListV2: React.FC = () => {
>
<div className="flex flex-col bg-white dark:bg-gray-900" style={{ height: '100vh', overflow: 'hidden' }}>
{/* Task Filters */}
<div className="flex-none px-4 py-3" style={{ height: '66px', flexShrink: 0 }}>
<div className="flex-none px-6 py-4" style={{ height: '74px', flexShrink: 0 }}>
<ImprovedTaskFilters position="list" />
</div>
{/* Spacing between filters and table */}
<div className="flex-none h-4" style={{ flexShrink: 0 }}></div>
{/* Table Container */}
<div
className="flex-1 overflow-auto border border-gray-200 dark:border-gray-700"
className="flex-1 overflow-auto border border-gray-200 dark:border-gray-700 mx-6 rounded-lg"
style={{
height: '600px',
maxHeight: '600px'

View File

@@ -261,7 +261,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
case 'dragHandle':
return (
<div
className={`flex items-center justify-center ${isSubtask ? '' : 'cursor-grab active:cursor-grabbing'}`}
className="flex items-center justify-center"
style={baseStyle}
{...(isSubtask ? {} : { ...attributes, ...listeners })}
>

View File

@@ -109,7 +109,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors h-full"
>
<PlusOutlined className="text-xs" />
{t('addSubTaskText')}
{t('addSubtaskText')}
</button>
) : (
<Input
@@ -121,7 +121,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
className="w-full h-full border-none shadow-none bg-transparent"
style={{
height: '100%',
minHeight: '42px',
minHeight: '32px',
padding: '0',
fontSize: '14px'
}}
@@ -137,10 +137,12 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
}, [isAdding, subtaskName, handleAddSubtask, handleCancel, t]);
return (
<div className="flex items-center min-w-max px-1 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[42px]">
{visibleColumns.map((column) =>
renderColumn(column.id, column.width)
)}
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px] border-b border-gray-200 dark:border-gray-700">
{visibleColumns.map((column, index) => (
<React.Fragment key={column.id}>
{renderColumn(column.id, column.width)}
</React.Fragment>
))}
</div>
);
});

View File

@@ -135,9 +135,11 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
return (
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px] border-b border-gray-200 dark:border-gray-700">
{visibleColumns.map((column) =>
renderColumn(column.id, column.width)
)}
{visibleColumns.map((column, index) => (
<React.Fragment key={column.id}>
{renderColumn(column.id, column.width)}
</React.Fragment>
))}
</div>
);
});

View File

@@ -285,7 +285,7 @@ export const DateCustomColumnCell: React.FC<{
onOpenChange={setIsOpen}
value={dateValue}
onChange={handleDateChange}
placeholder={dateValue ? "" : "Click to set date"}
placeholder={dateValue ? "" : "Set date"}
format="MMM DD, YYYY"
suffixIcon={null}
size="small"
@@ -468,7 +468,7 @@ export const SelectionCustomColumnCell: React.FC<{
: 'border-gray-200 text-gray-600 bg-gray-50'
}
`}>
Select an option
Select option
</div>
{/* Options */}
@@ -569,7 +569,7 @@ export const SelectionCustomColumnCell: React.FC<{
<>
<div className={`w-3 h-3 rounded-full border-2 border-dashed ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`} />
<span className={`text-sm ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}>
Select option
Select
</span>
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />

View File

@@ -20,7 +20,8 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
import { toggleField } from '@/features/task-management/taskListFields.slice';
import { toggleField, syncFieldWithDatabase } from '@/features/task-management/taskListFields.slice';
import { selectColumns } from '@/features/task-management/task-management.slice';
// Import Redux actions
import {
@@ -698,8 +699,10 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
isDarkMode,
}) => {
const { t } = useTranslation('task-list-filters');
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const fieldsRaw = useSelector((state: RootState) => state.taskManagementFields);
const columns = useSelector(selectColumns);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const fields = Array.isArray(fieldsRaw) ? fieldsRaw : [];
const sortedFields = useMemo(() => [...fields].sort((a, b) => a.order - b.order), [fields]);
@@ -792,7 +795,20 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
return (
<button
key={field.key}
onClick={() => dispatch(toggleField(field.key))}
onClick={() => {
// Toggle field locally first
dispatch(toggleField(field.key));
// Sync with database if projectId is available
if (projectId) {
dispatch(syncFieldWithDatabase({
projectId,
fieldKey: field.key,
visible: !field.visible,
columns
}));
}
}}
className={`
w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded
transition-colors duration-150 text-left

View File

@@ -1,4 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
import { updateColumnVisibility } from './task-management.slice';
import { ITaskListColumn } from '@/types/tasks/taskList.types';
export interface TaskListField {
key: string;
@@ -49,6 +51,71 @@ function saveFields(fields: TaskListField[]) {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(fields));
}
// Async thunk to sync field visibility with database
export const syncFieldWithDatabase = createAsyncThunk(
'taskManagementFields/syncFieldWithDatabase',
async (
{ projectId, fieldKey, visible, columns }: {
projectId: string;
fieldKey: string;
visible: boolean;
columns: ITaskListColumn[]
},
{ dispatch }
) => {
// Find the corresponding backend column
const backendColumn = columns.find(c => c.key === fieldKey);
if (backendColumn) {
// Update the column visibility in the database
await dispatch(updateColumnVisibility({
projectId,
item: {
...backendColumn,
pinned: visible
}
}));
}
return { fieldKey, visible };
}
);
// Async thunk to sync all fields with database
export const syncAllFieldsWithDatabase = createAsyncThunk(
'taskManagementFields/syncAllFieldsWithDatabase',
async (
{ projectId, fields, columns }: {
projectId: string;
fields: TaskListField[];
columns: ITaskListColumn[]
},
{ dispatch }
) => {
// Find fields that need to be synced
const fieldsToSync = fields.filter(field => {
const backendColumn = columns.find(c => c.key === field.key);
return backendColumn && (backendColumn.pinned ?? false) !== field.visible;
});
// Sync each field
const syncPromises = fieldsToSync.map(field => {
const backendColumn = columns.find(c => c.key === field.key);
if (backendColumn) {
return dispatch(updateColumnVisibility({
projectId,
item: {
...backendColumn,
pinned: field.visible
}
}));
}
return Promise.resolve();
});
await Promise.all(syncPromises);
return fieldsToSync.map(f => ({ fieldKey: f.key, visible: f.visible }));
}
);
const initialState: TaskListField[] = loadFields();
const taskListFieldsSlice = createSlice({
@@ -75,10 +142,42 @@ const taskListFieldsSlice = createSlice({
saveFields(defaultFields);
return defaultFields;
},
// New action to update field visibility from database
updateFieldVisibilityFromDatabase(state, action: PayloadAction<{ fieldKey: string; visible: boolean }>) {
const { fieldKey, visible } = action.payload;
const field = state.find(f => f.key === fieldKey);
if (field) {
field.visible = visible;
// Save to localStorage
saveFields(state);
}
},
},
extraReducers: (builder) => {
builder
.addCase(syncFieldWithDatabase.fulfilled, (state, action) => {
// Field visibility has been synced with database
const { fieldKey, visible } = action.payload;
const field = state.find(f => f.key === fieldKey);
if (field) {
field.visible = visible;
saveFields(state);
}
})
.addCase(syncAllFieldsWithDatabase.fulfilled, (state, action) => {
// All fields have been synced with database
action.payload.forEach(({ fieldKey, visible }) => {
const field = state.find(f => f.key === fieldKey);
if (field) {
field.visible = visible;
}
});
saveFields(state);
});
},
});
export const { toggleField, setFields, resetFields } = taskListFieldsSlice.actions;
export const { toggleField, setFields, resetFields, updateFieldVisibilityFromDatabase } = taskListFieldsSlice.actions;
// Utility function to force reset fields (can be called from browser console)
export const forceResetFields = () => {