From 7eb55b315af95b45fe737b15383297962aae4991 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 15 Jul 2025 17:19:30 +0530 Subject: [PATCH] feat(i18n): enhance internationalization support and optimize task updates - Introduced I18nReadyCheck component to ensure translations are loaded before rendering the app. - Updated various components to utilize the new i18n structure, improving translation management. - Implemented optimistic UI updates for task properties during drag-and-drop operations, enhancing responsiveness. - Added checks to prevent stale updates from socket events, ensuring the UI reflects the most recent task states. - Enhanced error handling and logging for i18n initialization and loading processes. --- worklenz-frontend/src/App.tsx | 42 +++++++++++------ .../task-list-v2/hooks/useDragAndDrop.ts | 24 ++++++++++ .../task-management/task-management.slice.ts | 27 ++++++++--- .../src/hooks/useTaskSocketHandlers.ts | 45 +++++++++++++++++++ worklenz-frontend/src/i18n.ts | 22 +++++++++ .../home/task-list/add-task-inline-form.tsx | 34 ++++++++------ .../pages/home/task-list/calendar-view.tsx | 11 +++-- .../src/pages/home/task-list/tasks-list.tsx | 11 +++-- .../src/pages/home/todo-list/todo-list.tsx | 23 ++++++---- 9 files changed, 192 insertions(+), 47 deletions(-) diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 0f29cdcd..7999b2d7 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -1,7 +1,8 @@ // Core dependencies -import React, { Suspense, useEffect, memo, useMemo, useCallback } from 'react'; +import React, { Suspense, useEffect, memo, useMemo, useCallback, useState } from 'react'; import { RouterProvider } from 'react-router-dom'; import i18next from 'i18next'; +import { useTranslation } from 'react-i18next'; // Components import ThemeWrapper from './features/theme/ThemeWrapper'; @@ -27,6 +28,24 @@ import { CSSPerformanceMonitor, LayoutStabilizer, CriticalCSSManager } from './u // Service Worker import { registerSW } from './utils/serviceWorkerRegistration'; +// i18n ready check component +const I18nReadyCheck: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { ready } = useTranslation(); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + if (ready) { + setIsReady(true); + } + }, [ready]); + + if (!isReady) { + return ; + } + + return <>{children}; +}; + /** * Main App Component - Performance Optimized * @@ -200,18 +219,15 @@ const App: React.FC = memo(() => { }, []); return ( - }> - - - - - - + + + + }> + + + + + ); }); diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts index 4a5b8848..8f1ebfa1 100644 --- a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts @@ -250,6 +250,30 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { newPosition: insertIndex, }); + // Optimistically update task properties immediately for UI responsiveness + const updatedTask = { + ...activeTask, + updatedAt: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + // Update task properties based on the grouping type + if (currentGrouping === 'priority') { + updatedTask.priority = targetGroup.id; + updatedTask.priority_id = targetGroup.id; + } else if (currentGrouping === 'phase') { + updatedTask.phase = targetGroup.id; + updatedTask.phase_id = targetGroup.id; + } else if (currentGrouping === 'status') { + updatedTask.status = targetGroup.id; + updatedTask.status_id = targetGroup.id; + } + + // Import and dispatch updateTask for immediate UI update + import('@/features/task-management/task-management.slice').then(({ updateTask }) => { + dispatch(updateTask(updatedTask)); + }); + // reorderTasksInGroup handles both same-group and cross-group moves // No need for separate moveTaskBetweenGroups call dispatch( diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index f0e9c494..2c123f3b 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -657,9 +657,15 @@ const taskManagementSlice = createSlice({ const group = state.groups.find(g => g.id === sourceGroupId); if (group) { const newTasks = Array.from(group.taskIds); - const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1); - newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed); - group.taskIds = newTasks; + const sourceIndex = newTasks.indexOf(sourceTaskId); + const destIndex = newTasks.indexOf(destinationTaskId); + + // Only proceed if both tasks are found + if (sourceIndex !== -1 && destIndex !== -1) { + const [removed] = newTasks.splice(sourceIndex, 1); + newTasks.splice(destIndex, 0, removed); + group.taskIds = newTasks; + } // Update order for affected tasks using the appropriate sort field const sortField = getSortOrderField(state.grouping?.id); @@ -681,13 +687,20 @@ const taskManagementSlice = createSlice({ // Add to destination group at the correct position relative to destinationTask const destinationIndex = destinationGroup.taskIds.indexOf(destinationTaskId); if (destinationIndex !== -1) { - destinationGroup.taskIds.splice(destinationIndex, 0, sourceTaskId); + // Ensure we don't add duplicate task IDs + if (!destinationGroup.taskIds.includes(sourceTaskId)) { + destinationGroup.taskIds.splice(destinationIndex, 0, sourceTaskId); + } } else { - destinationGroup.taskIds.push(sourceTaskId); // Add to end if destination task not found + // Add to end if destination task not found, but only if not already present + if (!destinationGroup.taskIds.includes(sourceTaskId)) { + destinationGroup.taskIds.push(sourceTaskId); + } } - // Do NOT update the task's grouping field (priority, phase, status) here. - // This will be handled by the socket event handler after backend confirmation. + // Note: Task's grouping field (priority, phase, status) will also be updated + // by the socket event handler after backend confirmation, but for immediate UI + // feedback, we update it optimistically in the drag and drop handler. // Update order for affected tasks in both groups using the appropriate sort field const sortField = getSortOrderField(state.grouping?.id); diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index 2cb419a9..5655dda2 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -217,6 +217,17 @@ export const useTaskSocketHandlers = () => { const currentGrouping = state.taskManagement.grouping; if (currentTask) { + // Check if current task has a more recent optimistic update + const currentUpdateTime = currentTask.updatedAt || currentTask.updated_at; + const serverUpdateTime = response.updated_at || response.updatedAt; + + // If current task was updated more recently than server data, skip this update + if (currentUpdateTime && serverUpdateTime && + new Date(currentUpdateTime).getTime() > new Date(serverUpdateTime).getTime()) { + console.log(`[TASK_STATUS_CHANGE] Skipping stale update for task ${response.id}`); + return; + } + // Determine the new status value based on status category let newStatusValue: 'todo' | 'doing' | 'done' = 'todo'; if (response.statusCategory) { @@ -393,6 +404,17 @@ export const useTaskSocketHandlers = () => { const currentGrouping = state.taskManagement.grouping; if (currentTask) { + // Check if current task has a more recent optimistic update + const currentUpdateTime = currentTask.updatedAt || currentTask.updated_at; + const serverUpdateTime = response.updated_at || response.updatedAt; + + // If current task was updated more recently than server data, skip this update + if (currentUpdateTime && serverUpdateTime && + new Date(currentUpdateTime).getTime() > new Date(serverUpdateTime).getTime()) { + console.log(`[TASK_PRIORITY_CHANGE] Skipping stale update for task ${response.id}`); + return; + } + // Get priority list to map priority_id to priority name const priorityList = state.priorityReducer?.priorities || []; let newPriorityValue: 'critical' | 'high' | 'medium' | 'low' = 'medium'; @@ -549,6 +571,17 @@ export const useTaskSocketHandlers = () => { const currentTask = state.taskManagement.entities[taskId]; if (currentTask) { + // Check if current task has a more recent optimistic update + const currentUpdateTime = currentTask.updatedAt || currentTask.updated_at; + const serverUpdateTime = data.updated_at || data.updatedAt; + + // If current task was updated more recently than server data, skip this update + if (currentUpdateTime && serverUpdateTime && + new Date(currentUpdateTime).getTime() > new Date(serverUpdateTime).getTime()) { + console.log(`[TASK_PHASE_CHANGE] Skipping stale update for task ${data.task_id}`); + return; + } + // Get phase list to map phase_id to phase name const phaseList = state.phaseReducer?.phaseList || []; let newPhaseValue = ''; @@ -1007,6 +1040,18 @@ export const useTaskSocketHandlers = () => { data.forEach((taskData: any) => { const currentTask = state.taskManagement.entities[taskData.id]; if (currentTask) { + // Check if current task has a more recent optimistic update + const currentUpdateTime = currentTask.updatedAt || currentTask.updated_at; + const serverUpdateTime = taskData.updated_at || taskData.updatedAt; + + // If current task was updated more recently than server data, skip this update + // This prevents socket updates from overwriting newer optimistic updates + if (currentUpdateTime && serverUpdateTime && + new Date(currentUpdateTime).getTime() > new Date(serverUpdateTime).getTime()) { + console.log(`[TASK_SORT_ORDER_CHANGE] Skipping stale update for task ${taskData.id}`); + return; + } + let updatedTask: Task = { ...currentTask, order: taskData.sort_order || taskData.current_sort_order || currentTask.order, diff --git a/worklenz-frontend/src/i18n.ts b/worklenz-frontend/src/i18n.ts index c7337343..8cff01f2 100644 --- a/worklenz-frontend/src/i18n.ts +++ b/worklenz-frontend/src/i18n.ts @@ -24,11 +24,33 @@ i18n backend: { loadPath: '/locales/{{lng}}/{{ns}}.json', + // Ensure translations are loaded synchronously + crossDomain: false, + withCredentials: false, }, react: { useSuspense: false, }, + + // Ensure all namespaces are loaded upfront + ns: ['common', 'home', 'task-management', 'task-list-table'], + + // Add initialization promise to ensure translations are loaded + initImmediate: false, }); +// Ensure translations are loaded before the app starts +i18n.on('initialized', () => { + console.log('i18n initialized successfully'); +}); + +i18n.on('loaded', (loaded) => { + console.log('i18n loaded:', loaded); +}); + +i18n.on('failedLoading', (lng, ns, msg) => { + console.error('i18n failed loading:', lng, ns, msg); +}); + export default i18n; diff --git a/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx b/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx index caff5f7f..440eb476 100644 --- a/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx +++ b/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx @@ -14,6 +14,8 @@ import { IMyTask } from '@/types/home/my-tasks.types'; import { useSocket } from '@/socket/socketContext'; import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response'; import dayjs from 'dayjs'; +import { useTranslation } from 'react-i18next'; +import { Skeleton } from 'antd'; interface AddTaskInlineFormProps { t: TFunction; @@ -25,35 +27,41 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => { const [isDueDateFieldShowing, setIsDueDateFieldShowing] = useState(false); const [isProjectFieldShowing, setIsProjectFieldShowing] = useState(false); const [form] = Form.useForm(); + const { ready } = useTranslation('home'); + + const { homeTasksConfig } = useAppSelector(state => state.homePageReducer); + const { data: projectListData, isFetching: projectListFetching } = useGetProjectsByTeamQuery(); + const { refetch } = useGetMyTasksQuery(homeTasksConfig); const currentSession = useAuthService().getCurrentSession(); const { socket } = useSocket(); - const { data: projectListData, isFetching: projectListFetching } = useGetProjectsByTeamQuery(); - const { homeTasksConfig } = useAppSelector(state => state.homePageReducer); - const { refetch } = useGetMyTasksQuery(homeTasksConfig); - const taskInputRef = useRef(null); + // Don't render until i18n is ready + if (!ready) { + return ; + } + const dueDateOptions = [ { value: 'Today', - label: t('home:tasks.today'), + label: t('tasks.today'), }, { value: 'Tomorrow', - label: t('home:tasks.tomorrow'), + label: t('tasks.tomorrow'), }, { value: 'Next Week', - label: t('home:tasks.nextWeek'), + label: t('tasks.nextWeek'), }, { value: 'Next Month', - label: t('home:tasks.nextMonth'), + label: t('tasks.nextMonth'), }, { value: 'No Due Date', - label: t('home:tasks.noDueDate'), + label: t('tasks.noDueDate'), }, ]; @@ -169,14 +177,14 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => { rules={[ { required: true, - message: t('home:tasks.taskRequired'), + message: t('tasks.taskRequired'), }, ]} > { const inputValue = e.currentTarget.value; @@ -200,7 +208,7 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => { - {t('home:tasks.pressTabToSelectDueDateAndProject')} + {t('tasks.pressTabToSelectDueDateAndProject')} } type="info" @@ -245,7 +253,7 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => { rules={[ { required: true, - message: t('home:tasks.projectRequired'), + message: t('tasks.projectRequired'), }, ]} > diff --git a/worklenz-frontend/src/pages/home/task-list/calendar-view.tsx b/worklenz-frontend/src/pages/home/task-list/calendar-view.tsx index c0bfac8f..7a43c106 100644 --- a/worklenz-frontend/src/pages/home/task-list/calendar-view.tsx +++ b/worklenz-frontend/src/pages/home/task-list/calendar-view.tsx @@ -1,5 +1,5 @@ import HomeCalendar from '../../../components/calendars/homeCalendar/HomeCalendar'; -import { Tag, Typography } from 'antd'; +import { Tag, Typography, Skeleton } from 'antd'; import { ClockCircleOutlined } from '@ant-design/icons'; import { useAppSelector } from '@/hooks/useAppSelector'; import AddTaskInlineForm from './add-task-inline-form'; @@ -10,7 +10,12 @@ import dayjs from 'dayjs'; const CalendarView = () => { const { homeTasksConfig } = useAppSelector(state => state.homePageReducer); - const { t } = useTranslation('home'); + const { t, ready } = useTranslation('home'); + + // Don't render until i18n is ready + if (!ready) { + return ; + } useEffect(() => { if (!homeTasksConfig.selected_date) { @@ -36,7 +41,7 @@ const CalendarView = () => { }} > - {t('home:tasks.dueOn')} {homeTasksConfig.selected_date?.format('MMM DD, YYYY')} + {t('tasks.dueOn')} {homeTasksConfig.selected_date?.format('MMM DD, YYYY')} diff --git a/worklenz-frontend/src/pages/home/task-list/tasks-list.tsx b/worklenz-frontend/src/pages/home/task-list/tasks-list.tsx index 01e7706c..1c0bc08d 100644 --- a/worklenz-frontend/src/pages/home/task-list/tasks-list.tsx +++ b/worklenz-frontend/src/pages/home/task-list/tasks-list.tsx @@ -61,18 +61,23 @@ const TasksList: React.FC = React.memo(() => { refetchOnFocus: false, }); - const { t } = useTranslation('home'); + const { t, ready } = useTranslation('home'); const { model } = useAppSelector(state => state.homePageReducer); + // Don't render until i18n is ready + if (!ready) { + return ; + } + const taskModes = useMemo( () => [ { value: 0, - label: t('home:tasks.assignedToMe'), + label: t('tasks.assignedToMe'), }, { value: 1, - label: t('home:tasks.assignedByMe'), + label: t('tasks.assignedByMe'), }, ], [t] diff --git a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx index f8715808..01675b62 100644 --- a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx +++ b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx @@ -10,6 +10,7 @@ import Tooltip from 'antd/es/tooltip'; import Typography from 'antd/es/typography'; import Button from 'antd/es/button'; import Alert from 'antd/es/alert'; +import Skeleton from 'antd/es/skeleton'; import EmptyListPlaceholder from '@components/EmptyListPlaceholder'; import { IMyTask } from '@/types/home/my-tasks.types'; @@ -24,7 +25,7 @@ import { useCreatePersonalTaskMutation } from '@/api/home-page/home-page.api.ser const TodoList = () => { const [isAlertShowing, setIsAlertShowing] = useState(false); const [form] = Form.useForm(); - const { t } = useTranslation('home'); + const { t, ready } = useTranslation('home'); const [createPersonalTask, { isLoading: isCreatingPersonalTask }] = useCreatePersonalTaskMutation(); @@ -35,6 +36,11 @@ const TodoList = () => { // ref for todo input field const todoInputRef = useRef(null); + // Don't render until i18n is ready + if (!ready) { + return ; + } + // function to handle todo submit const handleTodoSubmit = async (values: any) => { if (!values.name || values.name.trim() === '') return; @@ -43,6 +49,7 @@ const TodoList = () => { done: false, is_task: false, color_code: '#000', + manual_progress: false, }; const res = await createPersonalTask(newTodo); @@ -69,7 +76,7 @@ const TodoList = () => { width: 32, render: (record: IMyTask) => ( - +