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.
This commit is contained in:
chamiakJ
2025-07-06 16:54:11 +05:30
parent 6ba1ff57b2
commit c70f8e7b6d
10 changed files with 332 additions and 13 deletions

View File

@@ -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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
const q = `SELECT update_status_order($1);`;

View File

@@ -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));

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -60,6 +60,20 @@ export const statusApiService = {
return response.data;
},
updateStatusCategory: async (
statusId: string,
categoryId: string,
currentProjectId: string
): Promise<IServerResponse<ITaskStatus>> => {
const q = toQueryString({ current_project_id: currentProjectId });
const response = await apiClient.put<IServerResponse<ITaskStatus>>(
`${rootUrl}/category/${statusId}${q}`,
{ category_id: categoryId }
);
return response.data;
},
updateStatusOrder: async (
body: ITaskStatusCreateRequest,
currentProjectId: string

View File

@@ -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<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle }) => {
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ 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<TaskGroupHeaderProps> = ({ 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<HTMLInputElement>) => {
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: <PencilIcon className="h-4 w-4" />,
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: <ArrowPathIcon className="h-4 w-4" />,
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<TaskGroupHeaderProps> = ({ group, isCollapsed, o
{/* Group indicator and name - no gap at all */}
<div className="flex items-center flex-1 ml-1">
{/* Group name and count */}
<div className="flex items-center flex-1">
<span className="text-sm font-semibold">
{group.name} ({group.count})
<div className="flex items-center">
{isEditingName && isOwnerOrAdmin ? (
<Input
value={editingName}
onChange={(e) => 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')}
/>
) : (
<span
className={`text-sm font-semibold ${isOwnerOrAdmin ? 'cursor-pointer hover:bg-black hover:bg-opacity-10 dark:hover:bg-white dark:hover:bg-opacity-10 rounded px-2 py-1 transition-all duration-200 hover:shadow-sm' : ''}`}
onClick={handleNameClick}
style={{ color: headerTextColor }}
title={isOwnerOrAdmin ? t('clickToEditGroupName') : ''}
>
{group.name}
</span>
)}
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
({group.count})
</span>
</div>
{/* Three dots menu */}
<div className="flex items-center justify-center ml-2">
<Dropdown
menu={{ items: menuItems }}
trigger={['click']}
open={dropdownVisible}
onOpenChange={setDropdownVisible}
placement="bottomLeft"
>
<button
className="p-1 rounded-sm hover:bg-black hover:bg-opacity-10 transition-all duration-200 ease-out"
style={{ color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
setDropdownVisible(!dropdownVisible);
}}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</button>
</Dropdown>
</div>
</div>
{/* Change Category Modal */}
<Modal
title="Change Category"
open={categoryModalVisible}
onCancel={() => setCategoryModalVisible(false)}
footer={null}
width={400}
>
<div className="py-4">
<div className="space-y-2">
{statusCategories?.map((category) => (
<div
key={category.id}
className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
onClick={(e) => category.id && handleCategoryChange(category.id, e)}
>
<Flex align="center" gap={12}>
<Badge color={category.color_code} />
<span className="font-medium">{category.name}</span>
</Flex>
{isChangingCategory && (
<div className="text-blue-500">
<ArrowPathIcon className="h-4 w-4 animate-spin" />
</div>
)}
</div>
))}
</div>
</div>
</Modal>
</div>
);
};

View File

@@ -344,6 +344,7 @@ const TaskListV2: React.FC = () => {
}}
isCollapsed={isGroupCollapsed}
onToggle={() => handleGroupCollapse(group.id)}
projectId={urlProjectId || ''}
/>
{isGroupEmpty && !isGroupCollapsed && (
<div className="relative w-full">