Merge pull request #200 from Worklenz/fix/task-list-realtime-update
feat(assignee-selector): implement optimistic updates for assignee ma…
This commit is contained in:
@@ -5,6 +5,7 @@ import { PlusOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||
import { RootState } from '@/app/store';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
@@ -12,6 +13,7 @@ import { Avatar, Button, Checkbox } from '@/components';
|
||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||
import { updateTask } from '@/features/task-management/task-management.slice';
|
||||
|
||||
interface AssigneeSelectorProps {
|
||||
task: IProjectTask;
|
||||
@@ -28,6 +30,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||
const [optimisticAssignees, setOptimisticAssignees] = useState<string[]>([]); // For optimistic updates
|
||||
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set()); // Track pending member changes
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -134,6 +138,34 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
const handleMemberToggle = (memberId: string, checked: boolean) => {
|
||||
if (!memberId || !projectId || !task?.id || !currentSession?.id) return;
|
||||
|
||||
// Add to pending changes for visual feedback
|
||||
setPendingChanges(prev => new Set(prev).add(memberId));
|
||||
|
||||
// OPTIMISTIC UPDATE: Update local state immediately for instant UI feedback
|
||||
const currentAssignees = task?.assignees?.map(a => a.team_member_id) || [];
|
||||
let newAssigneeIds: string[];
|
||||
|
||||
if (checked) {
|
||||
// Adding assignee
|
||||
newAssigneeIds = [...currentAssignees, memberId];
|
||||
} else {
|
||||
// Removing assignee
|
||||
newAssigneeIds = currentAssignees.filter(id => id !== memberId);
|
||||
}
|
||||
|
||||
// Update optimistic state for immediate UI feedback in dropdown
|
||||
setOptimisticAssignees(newAssigneeIds);
|
||||
|
||||
// Update local team members state for dropdown UI
|
||||
setTeamMembers(prev => ({
|
||||
...prev,
|
||||
data: (prev.data || []).map(member =>
|
||||
member.id === memberId
|
||||
? { ...member, selected: checked }
|
||||
: member
|
||||
)
|
||||
}));
|
||||
|
||||
const body = {
|
||||
team_member_id: memberId,
|
||||
project_id: projectId,
|
||||
@@ -143,13 +175,26 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
parent_task: task.parent_task_id,
|
||||
};
|
||||
|
||||
// Emit socket event - the socket handler will update Redux with proper types
|
||||
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
|
||||
|
||||
// Remove from pending changes after a short delay (optimistic)
|
||||
setTimeout(() => {
|
||||
setPendingChanges(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(memberId);
|
||||
return newSet;
|
||||
});
|
||||
}, 500); // Remove pending state after 500ms
|
||||
};
|
||||
|
||||
const checkMemberSelected = (memberId: string) => {
|
||||
if (!memberId) return false;
|
||||
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
|
||||
return assignees?.includes(memberId) || false;
|
||||
// Use optimistic assignees if available, otherwise fall back to task assignees
|
||||
const assignees = optimisticAssignees.length > 0
|
||||
? optimisticAssignees
|
||||
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
|
||||
return assignees.includes(memberId);
|
||||
};
|
||||
|
||||
const handleInviteProjectMemberDrawer = () => {
|
||||
@@ -233,13 +278,28 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
handleMemberToggle(member.id || '', !isSelected);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
// Add visual feedback for immediate response
|
||||
transition: 'all 0.15s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<Checkbox
|
||||
checked={checkMemberSelected(member.id || '')}
|
||||
onChange={(checked) => handleMemberToggle(member.id || '', checked)}
|
||||
disabled={member.pending_invitation}
|
||||
disabled={member.pending_invitation || pendingChanges.has(member.id || '')}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
{pendingChanges.has(member.id || '') && (
|
||||
<div className={`absolute inset-0 flex items-center justify-center ${
|
||||
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
|
||||
}`}>
|
||||
<div className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
|
||||
isDarkMode ? 'border-blue-400' : 'border-blue-600'
|
||||
}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Avatar
|
||||
src={member.avatar_url}
|
||||
|
||||
@@ -425,8 +425,12 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Simplified column rendering for initial load
|
||||
const renderColumnSimple = useCallback((col: { key: string; width: number }, isFixed: boolean, index: number, totalColumns: number) => {
|
||||
const isLast = index === totalColumns - 1;
|
||||
const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
// Fix border logic: if this is a fixed column, only consider it "last" if there are no scrollable columns
|
||||
// If this is a scrollable column, use the normal logic
|
||||
const isActuallyLast = isFixed
|
||||
? (index === totalColumns - 1 && (!scrollableColumns || scrollableColumns.length === 0))
|
||||
: (index === totalColumns - 1);
|
||||
const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
|
||||
// Only render essential columns during initial load
|
||||
switch (col.key) {
|
||||
@@ -527,8 +531,12 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
}
|
||||
|
||||
// Full rendering logic (existing code)
|
||||
const isLast = index === totalColumns - 1;
|
||||
const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
// Fix border logic: if this is a fixed column, only consider it "last" if there are no scrollable columns
|
||||
// If this is a scrollable column, use the normal logic
|
||||
const isActuallyLast = isFixed
|
||||
? (index === totalColumns - 1 && (!scrollableColumns || scrollableColumns.length === 0))
|
||||
: (index === totalColumns - 1);
|
||||
const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
|
||||
switch (col.key) {
|
||||
case 'drag':
|
||||
@@ -558,7 +566,14 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
|
||||
case 'task':
|
||||
const cellStyle = editTaskName
|
||||
? { width: col.width, border: '1px solid #1890ff', background: isDarkMode ? '#232b3a' : '#f0f7ff', transition: 'border 0.2s' }
|
||||
? {
|
||||
width: col.width,
|
||||
borderTop: '1px solid #1890ff',
|
||||
borderBottom: '1px solid #1890ff',
|
||||
borderLeft: '1px solid #1890ff',
|
||||
background: isDarkMode ? '#232b3a' : '#f0f7ff',
|
||||
transition: 'border 0.2s'
|
||||
}
|
||||
: { width: col.width };
|
||||
|
||||
return (
|
||||
@@ -954,8 +969,9 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
}}
|
||||
>
|
||||
{fixedColumns.map((col, index) => {
|
||||
const isLast = index === fixedColumns.length - 1;
|
||||
const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
// Fix border logic for add subtask row: fixed columns should have right border if scrollable columns exist
|
||||
const isActuallyLast = index === fixedColumns.length - 1 && (!scrollableColumns || scrollableColumns.length === 0);
|
||||
const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
|
||||
if (col.key === 'task') {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user