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:
@@ -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);`;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -344,6 +344,7 @@ const TaskListV2: React.FC = () => {
|
||||
}}
|
||||
isCollapsed={isGroupCollapsed}
|
||||
onToggle={() => handleGroupCollapse(group.id)}
|
||||
projectId={urlProjectId || ''}
|
||||
/>
|
||||
{isGroupEmpty && !isGroupCollapsed && (
|
||||
<div className="relative w-full">
|
||||
|
||||
Reference in New Issue
Block a user