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.
This commit is contained in:
@@ -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 <SuspenseFallback />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Main App Component - Performance Optimized
|
||||
*
|
||||
@@ -200,18 +219,15 @@ const App: React.FC = memo(() => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ThemeWrapper>
|
||||
<ModuleErrorBoundary>
|
||||
<RouterProvider
|
||||
router={router}
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
}}
|
||||
/>
|
||||
</ModuleErrorBoundary>
|
||||
</ThemeWrapper>
|
||||
</Suspense>
|
||||
<I18nReadyCheck>
|
||||
<ModuleErrorBoundary>
|
||||
<ThemeWrapper>
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</ThemeWrapper>
|
||||
</ModuleErrorBoundary>
|
||||
</I18nReadyCheck>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<InputRef | null>(null);
|
||||
|
||||
// Don't render until i18n is ready
|
||||
if (!ready) {
|
||||
return <Skeleton active />;
|
||||
}
|
||||
|
||||
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'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Flex vertical gap={4}>
|
||||
<Input
|
||||
ref={taskInputRef}
|
||||
placeholder={t('home:tasks.addTask')}
|
||||
placeholder={t('tasks.addTask')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={e => {
|
||||
const inputValue = e.currentTarget.value;
|
||||
@@ -200,7 +208,7 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
||||
<Alert
|
||||
message={
|
||||
<Typography.Text style={{ fontSize: 11 }}>
|
||||
{t('home:tasks.pressTabToSelectDueDateAndProject')}
|
||||
{t('tasks.pressTabToSelectDueDateAndProject')}
|
||||
</Typography.Text>
|
||||
}
|
||||
type="info"
|
||||
@@ -245,7 +253,7 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('home:tasks.projectRequired'),
|
||||
message: t('tasks.projectRequired'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -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 <Skeleton active />;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!homeTasksConfig.selected_date) {
|
||||
@@ -36,7 +41,7 @@ const CalendarView = () => {
|
||||
}}
|
||||
>
|
||||
<Typography.Text>
|
||||
{t('home:tasks.dueOn')} {homeTasksConfig.selected_date?.format('MMM DD, YYYY')}
|
||||
{t('tasks.dueOn')} {homeTasksConfig.selected_date?.format('MMM DD, YYYY')}
|
||||
</Typography.Text>
|
||||
</Tag>
|
||||
|
||||
|
||||
@@ -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 <Skeleton active />;
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
@@ -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<InputRef | null>(null);
|
||||
|
||||
// Don't render until i18n is ready
|
||||
if (!ready) {
|
||||
return <Skeleton active />;
|
||||
}
|
||||
|
||||
// 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) => (
|
||||
<ConfigProvider wave={{ disabled: true }}>
|
||||
<Tooltip title={t('home:todoList.markAsDone')}>
|
||||
<Tooltip title={t('todoList.markAsDone')}>
|
||||
<Button
|
||||
type="text"
|
||||
className="borderless-icon-btn"
|
||||
@@ -100,11 +107,11 @@ const TodoList = () => {
|
||||
<Card
|
||||
title={
|
||||
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
|
||||
{t('home:todoList.title')} ({data?.body.length})
|
||||
{t('todoList.title')} ({data?.body.length})
|
||||
</Typography.Title>
|
||||
}
|
||||
extra={
|
||||
<Tooltip title={t('home:todoList.refreshTasks')}>
|
||||
<Tooltip title={t('todoList.refreshTasks')}>
|
||||
<Button shape="circle" icon={<SyncOutlined spin={isFetching} />} onClick={refetch} />
|
||||
</Tooltip>
|
||||
}
|
||||
@@ -116,7 +123,7 @@ const TodoList = () => {
|
||||
<Flex vertical>
|
||||
<Input
|
||||
ref={todoInputRef}
|
||||
placeholder={t('home:todoList.addTask')}
|
||||
placeholder={t('todoList.addTask')}
|
||||
onChange={e => {
|
||||
const inputValue = e.currentTarget.value;
|
||||
|
||||
@@ -128,8 +135,8 @@ const TodoList = () => {
|
||||
<Alert
|
||||
message={
|
||||
<Typography.Text style={{ fontSize: 11 }}>
|
||||
{t('home:todoList.pressEnter')} <strong>Enter</strong>{' '}
|
||||
{t('home:todoList.toCreate')}
|
||||
{t('todoList.pressEnter')} <strong>Enter</strong>{' '}
|
||||
{t('todoList.toCreate')}
|
||||
</Typography.Text>
|
||||
}
|
||||
type="info"
|
||||
@@ -148,7 +155,7 @@ const TodoList = () => {
|
||||
{data?.body.length === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
text={t('home:todoList.noTasks')}
|
||||
text={t('todoList.noTasks')}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
|
||||
Reference in New Issue
Block a user