From c70f8e7b6d72abad9f73b0c0c1e1fdf326cfa59b Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Sun, 6 Jul 2025 16:54:11 +0530 Subject: [PATCH] feat(task-statuses): add category update functionality and enhance localization - Implemented updateCategory method in TaskStatusesController to allow updating task status categories. - Added corresponding route for category updates in statusesApiRouter. - Enhanced task management localization by adding new translation keys for category-related actions in multiple languages. - Updated TaskGroupHeader component to support category changes with a modal for selecting categories. --- .../controllers/task-statuses-controller.ts | 19 ++ .../src/routes/apis/statuses-api-router.ts | 1 + .../public/locales/alb/task-management.json | 8 +- .../public/locales/de/task-management.json | 8 +- .../public/locales/en/task-management.json | 8 +- .../public/locales/es/task-management.json | 8 +- .../public/locales/pt/task-management.json | 8 +- .../status/status.api.service.ts | 14 + .../task-list-v2/TaskGroupHeader.tsx | 270 +++++++++++++++++- .../components/task-list-v2/TaskListV2.tsx | 1 + 10 files changed, 332 insertions(+), 13 deletions(-) diff --git a/worklenz-backend/src/controllers/task-statuses-controller.ts b/worklenz-backend/src/controllers/task-statuses-controller.ts index dbefe0dd..a20e0d7a 100644 --- a/worklenz-backend/src/controllers/task-statuses-controller.ts +++ b/worklenz-backend/src/controllers/task-statuses-controller.ts @@ -134,6 +134,25 @@ export default class TaskStatusesController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(true, data)); } + @HandleExceptions() + public static async updateCategory(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const hasMoreCategories = await TaskStatusesController.hasMoreCategories(req.params.id, req.query.current_project_id as string); + + if (!hasMoreCategories) + return res.status(200).send(new ServerResponse(false, null, existsErrorMessage).withTitle("Status category update failed!")); + + const q = ` + UPDATE task_statuses + SET category_id = $2 + WHERE id = $1 + AND project_id = $3 + RETURNING (SELECT color_code FROM sys_task_status_categories WHERE id = task_statuses.category_id), (SELECT color_code_dark FROM sys_task_status_categories WHERE id = task_statuses.category_id); + `; + const result = await db.query(q, [req.params.id, req.body.category_id, req.query.current_project_id]); + const [data] = result.rows; + return res.status(200).send(new ServerResponse(true, data)); + } + @HandleExceptions() public static async updateStatusOrder(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const q = `SELECT update_status_order($1);`; diff --git a/worklenz-backend/src/routes/apis/statuses-api-router.ts b/worklenz-backend/src/routes/apis/statuses-api-router.ts index f9f6f560..07b00f26 100644 --- a/worklenz-backend/src/routes/apis/statuses-api-router.ts +++ b/worklenz-backend/src/routes/apis/statuses-api-router.ts @@ -18,6 +18,7 @@ statusesApiRouter.put("/order", statusOrderValidator, safeControllerFunction(Tas statusesApiRouter.get("/categories", safeControllerFunction(TaskStatusesController.getCategories)); statusesApiRouter.get("/:id", idParamValidator, safeControllerFunction(TaskStatusesController.getById)); statusesApiRouter.put("/name/:id", projectManagerValidator, idParamValidator, taskStatusBodyValidator, safeControllerFunction(TaskStatusesController.updateName)); +statusesApiRouter.put("/category/:id", projectManagerValidator, idParamValidator, safeControllerFunction(TaskStatusesController.updateCategory)); statusesApiRouter.put("/:id", projectManagerValidator, idParamValidator, taskStatusBodyValidator, safeControllerFunction(TaskStatusesController.update)); statusesApiRouter.delete("/:id", projectManagerValidator, idParamValidator, statusDeleteValidator, safeControllerFunction(TaskStatusesController.deleteById)); diff --git a/worklenz-frontend/public/locales/alb/task-management.json b/worklenz-frontend/public/locales/alb/task-management.json index 9991e559..a156ef3f 100644 --- a/worklenz-frontend/public/locales/alb/task-management.json +++ b/worklenz-frontend/public/locales/alb/task-management.json @@ -11,5 +11,11 @@ "attachments": "bashkëngjitje", "enterSubtaskName": "Shkruani emrin e nën-detyrës...", "add": "Shto", - "cancel": "Anulo" + "cancel": "Anulo", + "renameGroup": "Riemërto Grupin", + "renameStatus": "Riemërto Statusin", + "renamePhase": "Riemërto Fazën", + "changeCategory": "Ndrysho Kategorinë", + "clickToEditGroupName": "Kliko për të ndryshuar emrin e grupit", + "enterGroupName": "Shkruani emrin e grupit" } diff --git a/worklenz-frontend/public/locales/de/task-management.json b/worklenz-frontend/public/locales/de/task-management.json index 720c442f..b20d94a4 100644 --- a/worklenz-frontend/public/locales/de/task-management.json +++ b/worklenz-frontend/public/locales/de/task-management.json @@ -11,5 +11,11 @@ "attachments": "Anhänge", "enterSubtaskName": "Unteraufgabenname eingeben...", "add": "Hinzufügen", - "cancel": "Abbrechen" + "cancel": "Abbrechen", + "renameGroup": "Gruppe umbenennen", + "renameStatus": "Status umbenennen", + "renamePhase": "Phase umbenennen", + "changeCategory": "Kategorie ändern", + "clickToEditGroupName": "Klicken Sie, um den Gruppennamen zu bearbeiten", + "enterGroupName": "Gruppennamen eingeben" } diff --git a/worklenz-frontend/public/locales/en/task-management.json b/worklenz-frontend/public/locales/en/task-management.json index a9e6814e..662d5081 100644 --- a/worklenz-frontend/public/locales/en/task-management.json +++ b/worklenz-frontend/public/locales/en/task-management.json @@ -11,5 +11,11 @@ "attachments": "attachments", "enterSubtaskName": "Enter subtask name...", "add": "Add", - "cancel": "Cancel" + "cancel": "Cancel", + "renameGroup": "Rename Group", + "renameStatus": "Rename Status", + "renamePhase": "Rename Phase", + "changeCategory": "Change Category", + "clickToEditGroupName": "Click to edit group name", + "enterGroupName": "Enter group name" } diff --git a/worklenz-frontend/public/locales/es/task-management.json b/worklenz-frontend/public/locales/es/task-management.json index a1266394..1c80304c 100644 --- a/worklenz-frontend/public/locales/es/task-management.json +++ b/worklenz-frontend/public/locales/es/task-management.json @@ -11,5 +11,11 @@ "attachments": "adjuntos", "enterSubtaskName": "Ingresa el nombre de la subtarea...", "add": "Añadir", - "cancel": "Cancelar" + "cancel": "Cancelar", + "renameGroup": "Renombrar Grupo", + "renameStatus": "Renombrar Estado", + "renamePhase": "Renombrar Fase", + "changeCategory": "Cambiar Categoría", + "clickToEditGroupName": "Haz clic para editar el nombre del grupo", + "enterGroupName": "Ingresa el nombre del grupo" } diff --git a/worklenz-frontend/public/locales/pt/task-management.json b/worklenz-frontend/public/locales/pt/task-management.json index dc8f86b9..946b3162 100644 --- a/worklenz-frontend/public/locales/pt/task-management.json +++ b/worklenz-frontend/public/locales/pt/task-management.json @@ -11,5 +11,11 @@ "attachments": "anexos", "enterSubtaskName": "Digite o nome da subtarefa...", "add": "Adicionar", - "cancel": "Cancelar" + "cancel": "Cancelar", + "renameGroup": "Renomear Grupo", + "renameStatus": "Renomear Status", + "renamePhase": "Renomear Fase", + "changeCategory": "Alterar Categoria", + "clickToEditGroupName": "Clique para editar o nome do grupo", + "enterGroupName": "Digite o nome do grupo" } diff --git a/worklenz-frontend/src/api/taskAttributes/status/status.api.service.ts b/worklenz-frontend/src/api/taskAttributes/status/status.api.service.ts index 363b7484..376fed69 100644 --- a/worklenz-frontend/src/api/taskAttributes/status/status.api.service.ts +++ b/worklenz-frontend/src/api/taskAttributes/status/status.api.service.ts @@ -60,6 +60,20 @@ export const statusApiService = { return response.data; }, + updateStatusCategory: async ( + statusId: string, + categoryId: string, + currentProjectId: string + ): Promise> => { + const q = toQueryString({ current_project_id: currentProjectId }); + + const response = await apiClient.put>( + `${rootUrl}/category/${statusId}${q}`, + { category_id: categoryId } + ); + return response.data; + }, + updateStatusOrder: async ( body: ITaskStatusCreateRequest, currentProjectId: string diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx index c545ce5f..8501ac7b 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -1,13 +1,25 @@ -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useState } from 'react'; import { useDroppable } from '@dnd-kit/core'; // @ts-ignore: Heroicons module types -import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; -import { Checkbox } from 'antd'; +import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; +import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd'; +import { useTranslation } from 'react-i18next'; import { getContrastColor } from '@/utils/colorUtils'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice'; -import { selectGroups } from '@/features/task-management/task-management.slice'; +import { selectGroups, fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import { selectCurrentGrouping } from '@/features/task-management/grouping.slice'; +import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; +import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; +import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice'; +import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; +import { useAuthService } from '@/hooks/useAuth'; +import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types'; +import { ITaskPhase } from '@/types/tasks/taskPhase.types'; +import logger from '@/utils/errorLogger'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events'; interface TaskGroupHeaderProps { group: { @@ -18,12 +30,25 @@ interface TaskGroupHeaderProps { }; isCollapsed: boolean; onToggle: () => void; + projectId: string; } -const TaskGroupHeader: React.FC = ({ group, isCollapsed, onToggle }) => { +const TaskGroupHeader: React.FC = ({ group, isCollapsed, onToggle, projectId }) => { + const { t } = useTranslation('task-management'); const dispatch = useAppDispatch(); const selectedTaskIds = useAppSelector(selectSelectedTaskIds); const groups = useAppSelector(selectGroups); + const currentGrouping = useAppSelector(selectCurrentGrouping); + const { statusCategories } = useAppSelector(state => state.taskStatusReducer); + const { trackMixpanelEvent } = useMixpanelTracking(); + const { isOwnerOrAdmin } = useAuthService(); + + const [dropdownVisible, setDropdownVisible] = useState(false); + const [categoryModalVisible, setCategoryModalVisible] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [isChangingCategory, setIsChangingCategory] = useState(false); + const [isEditingName, setIsEditingName] = useState(false); + const [editingName, setEditingName] = useState(group.name); const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color const headerTextColor = getContrastColor(headerBackgroundColor); @@ -67,6 +92,139 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o } }, [dispatch, isAllSelected, tasksInGroup]); + // Handle inline name editing + const handleNameSave = useCallback(async () => { + if (!editingName.trim() || editingName.trim() === group.name || isRenaming) return; + + setIsRenaming(true); + try { + if (currentGrouping === 'status') { + // Extract status ID from group ID (format: "status-{statusId}") + const statusId = group.id.replace('status-', ''); + const body: ITaskStatusUpdateModel = { + name: editingName.trim(), + project_id: projectId, + }; + + await statusApiService.updateNameOfStatus(statusId, body, projectId); + trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Status' }); + dispatch(fetchStatuses(projectId)); + + } else if (currentGrouping === 'phase') { + // Extract phase ID from group ID (format: "phase-{phaseId}") + const phaseId = group.id.replace('phase-', ''); + const body = { id: phaseId, name: editingName.trim() }; + + await phasesApiService.updateNameOfPhase(phaseId, body as ITaskPhase, projectId); + trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' }); + dispatch(fetchPhasesByProjectId(projectId)); + } + + // Refresh task list to get updated group names + dispatch(fetchTasksV3(projectId)); + setIsEditingName(false); + + } catch (error) { + logger.error('Error renaming group:', error); + setEditingName(group.name); + } finally { + setIsRenaming(false); + } + }, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]); + + const handleNameClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (!isOwnerOrAdmin) return; + setIsEditingName(true); + setEditingName(group.name); + }, [group.name, isOwnerOrAdmin]); + + const handleNameKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleNameSave(); + } else if (e.key === 'Escape') { + setIsEditingName(false); + setEditingName(group.name); + } + e.stopPropagation(); + }, [group.name, handleNameSave]); + + const handleNameBlur = useCallback(() => { + setIsEditingName(false); + setEditingName(group.name); + }, [group.name]); + + // Handle dropdown menu actions + const handleRenameGroup = useCallback(() => { + setDropdownVisible(false); + setIsEditingName(true); + setEditingName(group.name); + }, [group.name]); + + const handleChangeCategory = useCallback(() => { + setDropdownVisible(false); + setCategoryModalVisible(true); + }, []); + + + + // Handle category change + const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => { + e?.stopPropagation(); + if (isChangingCategory) return; + + setIsChangingCategory(true); + try { + // Extract status ID from group ID (format: "status-{statusId}") + const statusId = group.id.replace('status-', ''); + + await statusApiService.updateStatusCategory(statusId, categoryId, projectId); + trackMixpanelEvent(evt_project_board_column_setting_click, { 'Change category': 'Status' }); + + // Refresh status list and tasks + dispatch(fetchStatuses(projectId)); + dispatch(fetchTasksV3(projectId)); + setCategoryModalVisible(false); + + } catch (error) { + logger.error('Error changing category:', error); + } finally { + setIsChangingCategory(false); + } + }, [group.id, projectId, dispatch, trackMixpanelEvent, isChangingCategory]); + + // Create dropdown menu items + const menuItems = useMemo(() => { + if (!isOwnerOrAdmin) return []; + + const items = [ + { + key: 'rename', + icon: , + label: currentGrouping === 'status' ? t('renameStatus') : currentGrouping === 'phase' ? t('renamePhase') : t('renameGroup'), + onClick: (e: any) => { + e?.domEvent?.stopPropagation(); + handleRenameGroup(); + }, + }, + ]; + + // Only show "Change Category" when grouped by status + if (currentGrouping === 'status') { + items.push({ + key: 'changeCategory', + icon: , + label: t('changeCategory'), + onClick: (e: any) => { + e?.domEvent?.stopPropagation(); + handleChangeCategory(); + }, + }); + } + + return items; + }, [currentGrouping, handleRenameGroup, handleChangeCategory, isOwnerOrAdmin]); + // Make the group header droppable const { isOver, setNodeRef } = useDroppable({ id: group.id, @@ -133,12 +291,108 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o {/* Group indicator and name - no gap at all */}
{/* Group name and count */} -
- - {group.name} ({group.count}) +
+ {isEditingName && isOwnerOrAdmin ? ( + 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')} + /> + ) : ( + + {group.name} + + )} + + ({group.count})
+ + {/* Three dots menu */} +
+ + + +
+ + + + {/* Change Category Modal */} + setCategoryModalVisible(false)} + footer={null} + width={400} + > +
+
+ {statusCategories?.map((category) => ( +
category.id && handleCategoryChange(category.id, e)} + > + + + {category.name} + + {isChangingCategory && ( +
+ +
+ )} +
+ ))} +
+
+
); }; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx index 563d377a..59ca0d85 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx @@ -344,6 +344,7 @@ const TaskListV2: React.FC = () => { }} isCollapsed={isGroupCollapsed} onToggle={() => handleGroupCollapse(group.id)} + projectId={urlProjectId || ''} /> {isGroupEmpty && !isGroupCollapsed && (