From a1e8a4c464245e647ae01bc16474f446bc6d5bee Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 2 Jul 2025 16:01:04 +0530 Subject: [PATCH] feat(task-management): enhance bulk action bar and localization updates - Added new features to the OptimizedBulkActionBar, including dropdowns for labels and assignees, improving task management capabilities. - Integrated task template creation functionality for owners/admins, allowing users to create templates from selected tasks. - Updated localization files for multiple languages, adding new strings for label searching and template name requirements to enhance user experience. - Refactored LabelsDropdown component to support label filtering and improved UI feedback for label creation. --- .../alb/tasks/task-table-bulk-actions.json | 2 + .../de/tasks/task-table-bulk-actions.json | 2 + .../locales/en/task-template-drawer.json | 1 + .../en/tasks/task-table-bulk-actions.json | 2 + .../es/tasks/task-table-bulk-actions.json | 2 + .../pt/tasks/task-table-bulk-actions.json | 2 + .../optimized-bulk-action-bar.tsx | 291 +++++++++++++++++- .../task-management/task-list-board.tsx | 77 ++++- .../task-templates/task-template-drawer.tsx | 3 +- .../components/LabelsDropdown.tsx | 94 ++++-- 10 files changed, 425 insertions(+), 51 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/tasks/task-table-bulk-actions.json b/worklenz-frontend/public/locales/alb/tasks/task-table-bulk-actions.json index cb433bf9..45980b24 100644 --- a/worklenz-frontend/public/locales/alb/tasks/task-table-bulk-actions.json +++ b/worklenz-frontend/public/locales/alb/tasks/task-table-bulk-actions.json @@ -17,7 +17,9 @@ "createTaskTemplate": "Krijo Shabllon Detyre", "apply": "Apliko", "createLabel": "+ Krijo Etiketë", + "searchOrCreateLabel": "Kërko ose krijo etiketë...", "hitEnterToCreate": "Shtyp Enter për të krijuar", + "labelExists": "Etiketa ekziston tashmë", "pendingInvitation": "Ftesë në Pritje", "noMatchingLabels": "Asnjë etiketë që përputhet", "noLabels": "Asnjë etiketë" diff --git a/worklenz-frontend/public/locales/de/tasks/task-table-bulk-actions.json b/worklenz-frontend/public/locales/de/tasks/task-table-bulk-actions.json index 297987c5..e8b039f2 100644 --- a/worklenz-frontend/public/locales/de/tasks/task-table-bulk-actions.json +++ b/worklenz-frontend/public/locales/de/tasks/task-table-bulk-actions.json @@ -17,7 +17,9 @@ "createTaskTemplate": "Aufgabenvorlage erstellen", "apply": "Anwenden", "createLabel": "+ Label erstellen", + "searchOrCreateLabel": "Label suchen oder erstellen...", "hitEnterToCreate": "Enter drücken zum Erstellen", + "labelExists": "Label existiert bereits", "pendingInvitation": "Einladung ausstehend", "noMatchingLabels": "Keine passenden Labels", "noLabels": "Keine Labels", diff --git a/worklenz-frontend/public/locales/en/task-template-drawer.json b/worklenz-frontend/public/locales/en/task-template-drawer.json index f2e23bee..9bc59126 100644 --- a/worklenz-frontend/public/locales/en/task-template-drawer.json +++ b/worklenz-frontend/public/locales/en/task-template-drawer.json @@ -4,6 +4,7 @@ "cancelText": "Cancel", "saveText": "Save", "templateNameText": "Template Name", + "templateNameRequired": "Template name is required", "selectedTasks": "Selected Tasks", "removeTask": "Remove", "cancelButton": "Cancel", diff --git a/worklenz-frontend/public/locales/en/tasks/task-table-bulk-actions.json b/worklenz-frontend/public/locales/en/tasks/task-table-bulk-actions.json index 4beab4f6..99eb3178 100644 --- a/worklenz-frontend/public/locales/en/tasks/task-table-bulk-actions.json +++ b/worklenz-frontend/public/locales/en/tasks/task-table-bulk-actions.json @@ -17,7 +17,9 @@ "createTaskTemplate": "Create Task Template", "apply": "Apply", "createLabel": "+ Create Label", + "searchOrCreateLabel": "Search or create label...", "hitEnterToCreate": "Press Enter to create", + "labelExists": "Label already exists", "pendingInvitation": "Pending Invitation", "noMatchingLabels": "No matching labels", "noLabels": "No labels", diff --git a/worklenz-frontend/public/locales/es/tasks/task-table-bulk-actions.json b/worklenz-frontend/public/locales/es/tasks/task-table-bulk-actions.json index 94963c91..5ba35bdf 100644 --- a/worklenz-frontend/public/locales/es/tasks/task-table-bulk-actions.json +++ b/worklenz-frontend/public/locales/es/tasks/task-table-bulk-actions.json @@ -17,7 +17,9 @@ "createTaskTemplate": "Crear plantilla de tarea", "apply": "Aplicar", "createLabel": "+ Crear etiqueta", + "searchOrCreateLabel": "Buscar o crear etiqueta...", "hitEnterToCreate": "Presione Enter para crear", + "labelExists": "La etiqueta ya existe", "pendingInvitation": "Invitación Pendiente", "noMatchingLabels": "No hay etiquetas coincidentes", "noLabels": "Sin etiquetas", diff --git a/worklenz-frontend/public/locales/pt/tasks/task-table-bulk-actions.json b/worklenz-frontend/public/locales/pt/tasks/task-table-bulk-actions.json index 6581803a..8d03c678 100644 --- a/worklenz-frontend/public/locales/pt/tasks/task-table-bulk-actions.json +++ b/worklenz-frontend/public/locales/pt/tasks/task-table-bulk-actions.json @@ -17,7 +17,9 @@ "createTaskTemplate": "Criar Modelo de Tarefa", "apply": "Aplicar", "createLabel": "+ Criar etiqueta", + "searchOrCreateLabel": "Pesquisar ou criar etiqueta...", "hitEnterToCreate": "Pressione Enter para criar", + "labelExists": "A etiqueta já existe", "pendingInvitation": "Convite Pendente", "noMatchingLabels": "Nenhuma etiqueta correspondente", "noLabels": "Sem etiquetas", diff --git a/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx index 9ce9f643..50422656 100644 --- a/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx +++ b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback, useState, useEffect } from 'react'; +import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { Button, @@ -19,12 +19,23 @@ import { TagsOutlined, UsergroupAddOutlined, FlagOutlined, - BulbOutlined + BulbOutlined, + MoreOutlined } from '@ant-design/icons'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { RootState } from '@/app/store'; import { useAppSelector } from '@/hooks/useAppSelector'; +import { selectTasks } from '@/features/projects/bulkActions/bulkActionSlice'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { useBulkActionTranslations } from '@/hooks/useTranslationPreloader'; +import LabelsDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown'; +import AssigneesDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/AssigneesDropdown'; +import { ITaskLabel } from '@/types/tasks/taskLabel.types'; +import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types'; +import { InputRef } from 'antd/es/input'; +import { CheckboxChangeEvent } from 'antd/es/checkbox'; +import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer'; +import { useAuthService } from '@/hooks/useAuth'; const { Text } = Typography; @@ -139,12 +150,16 @@ const OptimizedBulkActionBarContent: React.FC = Rea onBulkSetDueDate, }) => { const { t, ready, isLoading } = useBulkActionTranslations(); + const dispatch = useDispatch(); const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); // Get data from Redux store const statusList = useAppSelector(state => state.taskStatusReducer.status); const priorityList = useAppSelector(state => state.priorityReducer.priorities); const phaseList = useAppSelector(state => state.phaseReducer.phaseList); + const labelsList = useAppSelector(state => state.taskLabelsReducer.labels); + const members = useAppSelector(state => state.teamMembersReducer.teamMembers); + const tasks = useAppSelector(state => state.taskManagement.entities); // Performance state management const [isVisible, setIsVisible] = useState(false); @@ -162,6 +177,20 @@ const OptimizedBulkActionBarContent: React.FC = Rea dueDate: false, }); + // Labels dropdown state + const [selectedLabels, setSelectedLabels] = useState([]); + const [createLabelText, setCreateLabelText] = useState(''); + const labelsInputRef = useRef(null); + + // Assignees dropdown state + const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false); + + // Task template state + const [showDrawer, setShowDrawer] = useState(false); + + // Auth service for permissions + const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); + // Smooth entrance animation useEffect(() => { if (totalSelected > 0) { @@ -200,6 +229,8 @@ const OptimizedBulkActionBarContent: React.FC = Rea })), [phaseList] ); + + // Menu click handlers const handleStatusMenuClick = useCallback((e: any) => { onBulkStatusChange?.(e.key); @@ -213,6 +244,126 @@ const OptimizedBulkActionBarContent: React.FC = Rea onBulkPhaseChange?.(e.key); }, [onBulkPhaseChange]); + const handleLabelsMenuClick = useCallback((e: any) => { + onBulkAddLabels?.([e.key]); + }, [onBulkAddLabels]); + + // Labels dropdown handlers + const handleLabelChange = useCallback((e: CheckboxChangeEvent, label: ITaskLabel) => { + if (e.target.checked) { + setSelectedLabels(prev => [...prev, label]); + } else { + setSelectedLabels(prev => prev.filter(l => l.id !== label.id)); + } + }, []); + + const handleApplyLabels = useCallback(async () => { + if (!projectId) return; + try { + updateLoadingState('labels', true); + const body = { + tasks: selectedTaskIds, + labels: selectedLabels, + text: selectedLabels.length > 0 ? null : createLabelText.trim() !== '' ? createLabelText.trim() : null, + }; + await onBulkAddLabels?.(selectedLabels.map(l => l.id).filter((id): id is string => id !== undefined)); + setCreateLabelText(''); + setSelectedLabels([]); + } catch (error) { + // Error handling is done in the parent component + } finally { + updateLoadingState('labels', false); + } + }, [selectedLabels, createLabelText, selectedTaskIds, projectId, onBulkAddLabels, updateLoadingState]); + + // Assignees dropdown handlers + const handleChangeAssignees = useCallback(async (selectedAssignees: ITeamMemberViewModel[]) => { + if (!projectId) return; + try { + updateLoadingState('assignMembers', true); + await onBulkAssignMembers?.(selectedAssignees.map(m => m.id).filter((id): id is string => id !== undefined)); + } catch (error) { + // Error handling is done in the parent component + } finally { + updateLoadingState('assignMembers', false); + } + }, [projectId, onBulkAssignMembers, updateLoadingState]); + + const onAssigneeDropdownOpenChange = useCallback((open: boolean) => { + setAssigneeDropdownOpen(open); + }, []); + + // Get selected task objects for template creation + const selectedTaskObjects = useMemo(() => { + return Object.values(tasks).filter((task: any) => selectedTaskIds.includes(task.id)); + }, [tasks, selectedTaskIds]); + + // Update Redux state when opening template drawer + const handleOpenTemplateDrawer = useCallback(() => { + // Convert Task objects to IProjectTask format for template creation + const projectTasks: IProjectTask[] = selectedTaskObjects.map((task: any) => ({ + id: task.id, + name: task.title, // Always use title as the name + task_key: task.task_key, + status: task.status, + status_id: task.status, + priority: task.priority, + phase_id: task.phase, + phase_name: task.phase, + description: task.description, + start_date: task.startDate, + end_date: task.dueDate, + total_hours: task.timeTracking?.estimated || 0, + total_minutes: task.timeTracking?.logged || 0, + progress: task.progress, + sub_tasks_count: task.sub_tasks_count || 0, + assignees: task.assignees?.map((assigneeId: string) => ({ + id: assigneeId, + name: '', + email: '', + avatar_url: '', + team_member_id: assigneeId, + project_member_id: assigneeId, + })) || [], + labels: task.labels || [], + manual_progress: false, + created_at: task.createdAt, + updated_at: task.updatedAt, + sort_order: task.order, + })); + + // Update the bulkActionReducer with selected tasks + dispatch(selectTasks(projectTasks)); + setShowDrawer(true); + }, [selectedTaskObjects, dispatch]); + + // Labels dropdown content + const labelsDropdownContent = useMemo(() => ( + } + onLabelChange={handleLabelChange} + onCreateLabelTextChange={setCreateLabelText} + onApply={handleApplyLabels} + t={t} + loading={loadingStates.labels} + /> + ), [labelsList, isDarkMode, createLabelText, selectedLabels, handleLabelChange, handleApplyLabels, t, loadingStates.labels]); + + // Assignees dropdown content + const assigneesDropdownContent = useMemo(() => ( + setAssigneeDropdownOpen(false)} + t={t} + /> + ), [members?.data, isDarkMode, handleChangeAssignees, t]); + // Memoized handlers with loading states const handleStatusChange = useCallback(async () => { updateLoadingState('status', true); @@ -466,13 +617,41 @@ const OptimizedBulkActionBarContent: React.FC = Rea {/* Change Labels */} - } - tooltip={t('ADD_LABELS')} - onClick={() => onBulkAddLabels?.([])} - loading={loadingStates.labels} - isDarkMode={isDarkMode} - /> + + labelsDropdownContent} + trigger={['click']} + placement="top" + arrow + onOpenChange={(open) => { + if (!open) { + setSelectedLabels([]); + setCreateLabelText(''); + } + }} + > + diff --git a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown.tsx b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown.tsx index 8c44ffd6..ceb7e6ff 100644 --- a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown.tsx +++ b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown.tsx @@ -35,6 +35,23 @@ const LabelsDropdown = ({ } }, []); + // Filter labels based on search input + const filteredLabels = useMemo(() => { + if (!createLabelText.trim()) { + return labelsList; + } + return labelsList.filter(label => + label.name?.toLowerCase().includes(createLabelText.toLowerCase()) + ); + }, [labelsList, createLabelText]); + + // Check if the search text matches any existing label exactly + const exactMatch = useMemo(() => { + return labelsList.some(label => + label.name?.toLowerCase() === createLabelText.toLowerCase() + ); + }, [labelsList, createLabelText]); + const isOnApply = () => { if (!createLabelText.trim() && selectedLabels.length === 0) return; onApply(); @@ -42,18 +59,17 @@ const LabelsDropdown = ({ return ( - {/* Always show the list, filtered by input */} - {!createLabelText && ( - 10 ? '200px' : 'auto', // Set max height if more than 10 labels - maxWidth: 250, + {/* Show filtered labels list */} + 10 ? '200px' : 'auto', + maxWidth: 250, }} > - {labelsList.length > 0 && ( - labelsList.map(label => ( + {filteredLabels.length > 0 ? ( + filteredLabels.map(label => ( )) - )} - - )} + ) : createLabelText.trim() ? ( + + + {t('noMatchingLabels')} + + + ) : ( + + + {t('noLabels')} + + + )} + onCreateLabelTextChange(e.currentTarget.value)} - placeholder={t('createLabel')} + placeholder={t('searchOrCreateLabel')} onPressEnter={() => { isOnApply(); }} /> {createLabelText && ( - {t('hitEnterToCreate')} + {exactMatch + ? t('labelExists') + : t('hitEnterToCreate') + } )} - {!createLabelText && ( - - )} +