From e74668c3890ab94a18f2fa89a9f9706cc461ce7f Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 27 Jun 2025 13:24:01 +0530 Subject: [PATCH] feat(localization): update project view header translations and enhance UI functionality - Added new translations for "Import task", "Refresh project", "Save as template", and "Invite" in Albanian, German, English, Spanish, and Portuguese. - Refactored `ProjectViewHeader` component to optimize rendering with memoization and improved state management. - Enhanced task creation and subscription handling with better performance and error management. - Improved dropdown and button actions for a more intuitive user experience. --- .../alb/project-view/project-view-header.json | 6 +- .../de/project-view/project-view-header.json | 6 +- .../en/project-view/project-view-header.json | 6 +- .../es/project-view/project-view-header.json | 6 +- .../pt/project-view/project-view-header.json | 6 +- .../projectView/project-view-header.tsx | 356 ++++++++++++------ 6 files changed, 262 insertions(+), 124 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/project-view/project-view-header.json b/worklenz-frontend/public/locales/alb/project-view/project-view-header.json index 8d7b9d39..3335738f 100644 --- a/worklenz-frontend/public/locales/alb/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/alb/project-view/project-view-header.json @@ -1,5 +1,6 @@ { "importTasks": "Importo detyra", + "importTask": "Importo detyrë", "createTask": "Krijo detyrë", "settings": "Cilësimet", "subscribe": "Abonohu", @@ -9,5 +10,8 @@ "endDate": "Data e përfundimit", "projectSettings": "Cilësimet e projektit", "projectSummary": "Përmbledhja e projektit", - "receiveProjectSummary": "Merrni një përmbledhje të projektit çdo mbrëmje." + "receiveProjectSummary": "Merrni një përmbledhje të projektit çdo mbrëmje.", + "refreshProject": "Rifresko projektin", + "saveAsTemplate": "Ruaje si shabllon", + "invite": "Fto" } diff --git a/worklenz-frontend/public/locales/de/project-view/project-view-header.json b/worklenz-frontend/public/locales/de/project-view/project-view-header.json index ad236a04..e2810462 100644 --- a/worklenz-frontend/public/locales/de/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/de/project-view/project-view-header.json @@ -1,5 +1,6 @@ { "importTasks": "Aufgaben importieren", + "importTask": "Aufgabe importieren", "createTask": "Aufgabe erstellen", "settings": "Einstellungen", "subscribe": "Abonnieren", @@ -9,5 +10,8 @@ "endDate": "Enddatum", "projectSettings": "Projekteinstellungen", "projectSummary": "Projektzusammenfassung", - "receiveProjectSummary": "Erhalten Sie jeden Abend eine Projektzusammenfassung." + "receiveProjectSummary": "Erhalten Sie jeden Abend eine Projektzusammenfassung.", + "refreshProject": "Projekt aktualisieren", + "saveAsTemplate": "Als Vorlage speichern", + "invite": "Einladen" } diff --git a/worklenz-frontend/public/locales/en/project-view/project-view-header.json b/worklenz-frontend/public/locales/en/project-view/project-view-header.json index 8100e068..9a629679 100644 --- a/worklenz-frontend/public/locales/en/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/en/project-view/project-view-header.json @@ -1,5 +1,6 @@ { "importTasks": "Import tasks", + "importTask": "Import task", "createTask": "Create task", "settings": "Settings", "subscribe": "Subscribe", @@ -9,5 +10,8 @@ "endDate": "End date", "projectSettings": "Project settings", "projectSummary": "Project summary", - "receiveProjectSummary": "Receive a project summary every evening." + "receiveProjectSummary": "Receive a project summary every evening.", + "refreshProject": "Refresh project", + "saveAsTemplate": "Save as template", + "invite": "Invite" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/project-view/project-view-header.json b/worklenz-frontend/public/locales/es/project-view/project-view-header.json index de6020cf..bf42008e 100644 --- a/worklenz-frontend/public/locales/es/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/es/project-view/project-view-header.json @@ -1,5 +1,6 @@ { "importTasks": "Importar tareas", + "importTask": "Importar tarea", "createTask": "Crear tarea", "settings": "Ajustes", "subscribe": "Suscribirse", @@ -9,5 +10,8 @@ "endDate": "Fecha de finalización", "projectSettings": "Ajustes del proyecto", "projectSummary": "Resumen del proyecto", - "receiveProjectSummary": "Recibir un resumen del proyecto todas las noches." + "receiveProjectSummary": "Recibir un resumen del proyecto todas las noches.", + "refreshProject": "Actualizar proyecto", + "saveAsTemplate": "Guardar como plantilla", + "invite": "Invitar" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/project-view/project-view-header.json b/worklenz-frontend/public/locales/pt/project-view/project-view-header.json index 194668eb..4e27c8a1 100644 --- a/worklenz-frontend/public/locales/pt/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/pt/project-view/project-view-header.json @@ -1,5 +1,6 @@ { "importTasks": "Importar tarefas", + "importTask": "Importar tarefa", "createTask": "Criar tarefa", "settings": "Configurações", "subscribe": "Inscrever-se", @@ -9,5 +10,8 @@ "endDate": "Data de fim", "projectSettings": "Configurações do projeto", "projectSummary": "Resumo do projeto", - "receiveProjectSummary": "Receber um resumo do projeto todas as noites." + "receiveProjectSummary": "Receber um resumo do projeto todas as noites.", + "refreshProject": "Atualizar projeto", + "saveAsTemplate": "Salvar como modelo", + "invite": "Convidar" } \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx index 1d639d9d..4cfc5f86 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx @@ -20,6 +20,8 @@ import { import { PageHeader } from '@ant-design/pro-components'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; +import { useState, useCallback, useMemo, memo, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import { colors } from '@/styles/colors'; import { useAppDispatch } from '@/hooks/useAppDispatch'; @@ -39,13 +41,11 @@ import { setProjectId, } from '@/features/project/project-drawer.slice'; import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice'; -import { useState } from 'react'; import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types'; import { DEFAULT_TASK_NAME, UNMAPPED } from '@/shared/constants'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { getGroupIdByGroupedColumn } from '@/services/task-list/taskList.service'; import logger from '@/utils/errorLogger'; -import { createPortal } from 'react-dom'; import ImportTaskTemplate from '@/components/task-templates/import-task-template'; import ProjectDrawer from '@/components/projects/project-drawer/project-drawer'; import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; @@ -56,127 +56,139 @@ import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/ import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; -const ProjectViewHeader = () => { +const ProjectViewHeader = memo(() => { const navigate = useNavigate(); const { t } = useTranslation('project-view/project-view-header'); const dispatch = useAppDispatch(); - const currentSession = useAuthService().getCurrentSession(); - const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); - const isProjectManager = useIsProjectManager(); const { tab } = useTabSearchParam(); + // Memoize auth service calls to prevent unnecessary re-evaluations + const authService = useAuthService(); + const currentSession = useMemo(() => authService.getCurrentSession(), [authService]); + const isOwnerOrAdmin = useMemo(() => authService.isOwnerOrAdmin(), [authService]); + const isProjectManager = useIsProjectManager(); + const { socket } = useSocket(); - const { - project: selectedProject, - projectId, - } = useAppSelector(state => state.projectReducer); - const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer); + // Optimized selectors with shallow equality checks + const selectedProject = useAppSelector(state => state.projectReducer.project); + const projectId = useAppSelector(state => state.projectReducer.projectId); + const loadingGroups = useAppSelector(state => state.taskReducer.loadingGroups); + const groupBy = useAppSelector(state => state.taskReducer.groupBy); const [creatingTask, setCreatingTask] = useState(false); const [subscriptionLoading, setSubscriptionLoading] = useState(false); - const handleRefresh = () => { + // Use ref to track subscription timeout + const subscriptionTimeoutRef = useRef(null); + + // Memoized refresh handler with optimized dependencies + const handleRefresh = useCallback(() => { if (!projectId) return; + dispatch(getProject(projectId)); + switch (tab) { case 'tasks-list': dispatch(fetchTaskListColumns(projectId)); - dispatch(fetchPhasesByProjectId(projectId)) + dispatch(fetchPhasesByProjectId(projectId)); dispatch(fetchTaskGroups(projectId)); - // Also refresh the enhanced tasks data dispatch(fetchTasksV3(projectId)); break; case 'board': - // dispatch(fetchBoardTaskGroups(projectId)); dispatch(fetchEnhancedKanbanGroups(projectId)); break; case 'project-insights-member-overview': - dispatch(setRefreshTimestamp()); - break; case 'all-attachments': - dispatch(setRefreshTimestamp()); - break; case 'members': - dispatch(setRefreshTimestamp()); - break; case 'updates': dispatch(setRefreshTimestamp()); break; - default: - break; } - }; + }, [dispatch, projectId, tab]); - const handleSubscribe = () => { + // Optimized subscription handler with proper cleanup + const handleSubscribe = useCallback(() => { if (!selectedProject?.id || !socket || subscriptionLoading) return; try { setSubscriptionLoading(true); const newSubscriptionState = !selectedProject.subscribed; - // Emit socket event first, then update state based on response + // Clear any existing timeout + if (subscriptionTimeoutRef.current) { + clearTimeout(subscriptionTimeoutRef.current); + } + + // Emit socket event socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), { project_id: selectedProject.id, user_id: currentSession?.id, team_member_id: currentSession?.team_member_id, - mode: newSubscriptionState ? 0 : 1, // Fixed: 0 for subscribe, 1 for unsubscribe + mode: newSubscriptionState ? 0 : 1, }); - // Listen for the response to confirm the operation - socket.once(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), (response) => { + // Listen for response with cleanup + const handleResponse = (response: any) => { try { - // Update the project state with the confirmed subscription status dispatch(setProject({ ...selectedProject, subscribed: newSubscriptionState })); } catch (error) { logger.error('Error handling project subscription response:', error); - // Revert optimistic update on error dispatch(setProject({ ...selectedProject, subscribed: selectedProject.subscribed })); } finally { setSubscriptionLoading(false); + if (subscriptionTimeoutRef.current) { + clearTimeout(subscriptionTimeoutRef.current); + subscriptionTimeoutRef.current = null; + } } - }); + }; - // Add timeout in case socket response never comes - setTimeout(() => { - if (subscriptionLoading) { - setSubscriptionLoading(false); - logger.error('Project subscription timeout - no response from server'); - } + socket.once(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), handleResponse); + + // Set timeout with ref tracking + subscriptionTimeoutRef.current = setTimeout(() => { + setSubscriptionLoading(false); + logger.error('Project subscription timeout - no response from server'); + subscriptionTimeoutRef.current = null; }, 5000); } catch (error) { logger.error('Error updating project subscription:', error); setSubscriptionLoading(false); } - }; + }, [selectedProject, socket, subscriptionLoading, currentSession, dispatch]); - const handleSettingsClick = () => { + // Memoized settings handler + const handleSettingsClick = useCallback(() => { if (selectedProject?.id) { dispatch(setProjectId(selectedProject.id)); dispatch(fetchProjectData(selectedProject.id)); dispatch(toggleProjectDrawer()); } - }; + }, [dispatch, selectedProject?.id]); + + // Optimized task creation handler + const handleCreateTask = useCallback(() => { + if (!selectedProject?.id || !currentSession?.id || !socket) return; - const handleCreateTask = () => { try { setCreatingTask(true); const body: Partial = { name: DEFAULT_TASK_NAME, - project_id: selectedProject?.id, - reporter_id: currentSession?.id, - team_id: currentSession?.team_id, + project_id: selectedProject.id, + reporter_id: currentSession.id, + team_id: currentSession.team_id, }; - socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => { + const handleTaskCreated = (task: IProjectTask) => { if (task.id) { dispatch(setSelectedTaskId(task.id)); dispatch(setShowTaskDrawer(true)); @@ -188,100 +200,155 @@ const ProjectViewHeader = () => { } else { dispatch(addTask({ task, groupId })); } - socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); + socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); } } - }); - socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body)); + setCreatingTask(false); + }; + + socket.once(SocketEvents.QUICK_TASK.toString(), handleTaskCreated); + socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body)); } catch (error) { logger.error('Error creating task', error); - } finally { setCreatingTask(false); } - }; + }, [selectedProject?.id, currentSession, socket, dispatch, groupBy, tab]); - const handleImportTaskTemplate = () => { + // Memoized import task template handler + const handleImportTaskTemplate = useCallback(() => { dispatch(setImportTaskTemplateDrawerOpen(true)); - }; + }, [dispatch]); - const dropdownItems = [ + // Memoized navigation handler + const handleNavigateToProjects = useCallback(() => { + navigate('/worklenz/projects'); + }, [navigate]); + + // Memoized save as template handler + const handleSaveAsTemplate = useCallback(() => { + dispatch(toggleSaveAsTemplateDrawer()); + }, [dispatch]); + + // Memoized invite handler + const handleInvite = useCallback(() => { + dispatch(toggleProjectMemberDrawer()); + }, [dispatch]); + + // Memoized dropdown items + const dropdownItems = useMemo(() => [ { key: 'import', label: (
- Import task + {t('importTask')}
), }, - ]; + ], [handleImportTaskTemplate, t]); - const renderProjectAttributes = () => ( - - {selectedProject?.category_id && ( - + // Memoized project attributes with optimized date formatting + const projectAttributes = useMemo(() => { + if (!selectedProject) return null; + + const elements = []; + + if (selectedProject.category_id) { + elements.push( + {selectedProject.category_name} - )} + ); + } - {selectedProject?.status && ( - + if (selectedProject.status) { + elements.push( + - )} + ); + } - {(selectedProject?.start_date || selectedProject?.end_date) && ( - - {selectedProject?.start_date && - `${t('startDate')}: ${formatDate(new Date(selectedProject.start_date))}`} - {selectedProject?.end_date && ( - <> -
- {`${t('endDate')}: ${formatDate(new Date(selectedProject.end_date))}`} - - )} - - } - > + if (selectedProject.start_date || selectedProject.end_date) { + const tooltipContent = ( + + {selectedProject.start_date && + `${t('startDate')}: ${formatDate(new Date(selectedProject.start_date))}`} + {selectedProject.end_date && ( + <> +
+ {`${t('endDate')}: ${formatDate(new Date(selectedProject.end_date))}`} + + )} +
+ ); + + elements.push( + - )} + ); + } - {selectedProject?.notes && ( - {selectedProject.notes} - )} -
- ); + if (selectedProject.notes) { + elements.push( + + {selectedProject.notes} + + ); + } - const renderHeaderActions = () => ( - - + return ( + + {elements} + + ); + }, [selectedProject, t]); + + // Memoized header actions with conditional rendering optimization + const headerActions = useMemo(() => { + const actions = []; + + // Refresh button + actions.push( + + ); - {(isOwnerOrAdmin || isProjectManager) && ( + // Invite button (owner/admin/project manager only) + if (isOwnerOrAdmin || isProjectManager) { + actions.push( - )} + ); + } - {isOwnerOrAdmin ? ( + // Create task button + if (isOwnerOrAdmin) { + actions.push( } @@ -313,8 +388,11 @@ const ProjectViewHeader = () => { > {t('createTask')} - ) : ( + ); + } else { + actions.push( - )} + ); + } + + return ( + + {actions} + + ); + }, [ + loadingGroups, + handleRefresh, + isOwnerOrAdmin, + handleSaveAsTemplate, + handleSettingsClick, + t, + subscriptionLoading, + selectedProject?.subscribed, + handleSubscribe, + isProjectManager, + handleInvite, + creatingTask, + dropdownItems, + handleCreateTask, + ]); + + // Memoized page header title + const pageHeaderTitle = useMemo(() => ( + + + + {selectedProject?.name} + + {projectAttributes} - ); + ), [handleNavigateToProjects, selectedProject?.name, projectAttributes]); + + // Memoized page header styles + const pageHeaderStyle = useMemo(() => ({ + paddingInline: 0, + marginBlockEnd: 12, + }), []); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (subscriptionTimeoutRef.current) { + clearTimeout(subscriptionTimeoutRef.current); + } + }; + }, []); return ( <> - navigate('/worklenz/projects')} - /> - - {selectedProject?.name} - - {renderProjectAttributes()} - - } - style={{ paddingInline: 0, marginBlockEnd: 12 }} - extra={renderHeaderActions()} + title={pageHeaderTitle} + style={pageHeaderStyle} + extra={headerActions} /> - {createPortal( { }} />, document.body, 'project-drawer')} + {createPortal( {}} />, document.body, 'project-drawer')} {createPortal(, document.body, 'import-task-template')} {createPortal(, document.body, 'save-project-as-template')} - ); -}; +}); + +ProjectViewHeader.displayName = 'ProjectViewHeader'; export default ProjectViewHeader;