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.
This commit is contained in:
@@ -17,7 +17,9 @@
|
|||||||
"createTaskTemplate": "Krijo Shabllon Detyre",
|
"createTaskTemplate": "Krijo Shabllon Detyre",
|
||||||
"apply": "Apliko",
|
"apply": "Apliko",
|
||||||
"createLabel": "+ Krijo Etiketë",
|
"createLabel": "+ Krijo Etiketë",
|
||||||
|
"searchOrCreateLabel": "Kërko ose krijo etiketë...",
|
||||||
"hitEnterToCreate": "Shtyp Enter për të krijuar",
|
"hitEnterToCreate": "Shtyp Enter për të krijuar",
|
||||||
|
"labelExists": "Etiketa ekziston tashmë",
|
||||||
"pendingInvitation": "Ftesë në Pritje",
|
"pendingInvitation": "Ftesë në Pritje",
|
||||||
"noMatchingLabels": "Asnjë etiketë që përputhet",
|
"noMatchingLabels": "Asnjë etiketë që përputhet",
|
||||||
"noLabels": "Asnjë etiketë"
|
"noLabels": "Asnjë etiketë"
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"createTaskTemplate": "Aufgabenvorlage erstellen",
|
"createTaskTemplate": "Aufgabenvorlage erstellen",
|
||||||
"apply": "Anwenden",
|
"apply": "Anwenden",
|
||||||
"createLabel": "+ Label erstellen",
|
"createLabel": "+ Label erstellen",
|
||||||
|
"searchOrCreateLabel": "Label suchen oder erstellen...",
|
||||||
"hitEnterToCreate": "Enter drücken zum Erstellen",
|
"hitEnterToCreate": "Enter drücken zum Erstellen",
|
||||||
|
"labelExists": "Label existiert bereits",
|
||||||
"pendingInvitation": "Einladung ausstehend",
|
"pendingInvitation": "Einladung ausstehend",
|
||||||
"noMatchingLabels": "Keine passenden Labels",
|
"noMatchingLabels": "Keine passenden Labels",
|
||||||
"noLabels": "Keine Labels",
|
"noLabels": "Keine Labels",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"cancelText": "Cancel",
|
"cancelText": "Cancel",
|
||||||
"saveText": "Save",
|
"saveText": "Save",
|
||||||
"templateNameText": "Template Name",
|
"templateNameText": "Template Name",
|
||||||
|
"templateNameRequired": "Template name is required",
|
||||||
"selectedTasks": "Selected Tasks",
|
"selectedTasks": "Selected Tasks",
|
||||||
"removeTask": "Remove",
|
"removeTask": "Remove",
|
||||||
"cancelButton": "Cancel",
|
"cancelButton": "Cancel",
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"createTaskTemplate": "Create Task Template",
|
"createTaskTemplate": "Create Task Template",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"createLabel": "+ Create Label",
|
"createLabel": "+ Create Label",
|
||||||
|
"searchOrCreateLabel": "Search or create label...",
|
||||||
"hitEnterToCreate": "Press Enter to create",
|
"hitEnterToCreate": "Press Enter to create",
|
||||||
|
"labelExists": "Label already exists",
|
||||||
"pendingInvitation": "Pending Invitation",
|
"pendingInvitation": "Pending Invitation",
|
||||||
"noMatchingLabels": "No matching labels",
|
"noMatchingLabels": "No matching labels",
|
||||||
"noLabels": "No labels",
|
"noLabels": "No labels",
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"createTaskTemplate": "Crear plantilla de tarea",
|
"createTaskTemplate": "Crear plantilla de tarea",
|
||||||
"apply": "Aplicar",
|
"apply": "Aplicar",
|
||||||
"createLabel": "+ Crear etiqueta",
|
"createLabel": "+ Crear etiqueta",
|
||||||
|
"searchOrCreateLabel": "Buscar o crear etiqueta...",
|
||||||
"hitEnterToCreate": "Presione Enter para crear",
|
"hitEnterToCreate": "Presione Enter para crear",
|
||||||
|
"labelExists": "La etiqueta ya existe",
|
||||||
"pendingInvitation": "Invitación Pendiente",
|
"pendingInvitation": "Invitación Pendiente",
|
||||||
"noMatchingLabels": "No hay etiquetas coincidentes",
|
"noMatchingLabels": "No hay etiquetas coincidentes",
|
||||||
"noLabels": "Sin etiquetas",
|
"noLabels": "Sin etiquetas",
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"createTaskTemplate": "Criar Modelo de Tarefa",
|
"createTaskTemplate": "Criar Modelo de Tarefa",
|
||||||
"apply": "Aplicar",
|
"apply": "Aplicar",
|
||||||
"createLabel": "+ Criar etiqueta",
|
"createLabel": "+ Criar etiqueta",
|
||||||
|
"searchOrCreateLabel": "Pesquisar ou criar etiqueta...",
|
||||||
"hitEnterToCreate": "Pressione Enter para criar",
|
"hitEnterToCreate": "Pressione Enter para criar",
|
||||||
|
"labelExists": "A etiqueta já existe",
|
||||||
"pendingInvitation": "Convite Pendente",
|
"pendingInvitation": "Convite Pendente",
|
||||||
"noMatchingLabels": "Nenhuma etiqueta correspondente",
|
"noMatchingLabels": "Nenhuma etiqueta correspondente",
|
||||||
"noLabels": "Sem etiquetas",
|
"noLabels": "Sem etiquetas",
|
||||||
|
|||||||
@@ -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 { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -19,12 +19,23 @@ import {
|
|||||||
TagsOutlined,
|
TagsOutlined,
|
||||||
UsergroupAddOutlined,
|
UsergroupAddOutlined,
|
||||||
FlagOutlined,
|
FlagOutlined,
|
||||||
BulbOutlined
|
BulbOutlined,
|
||||||
|
MoreOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
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 { 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;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -139,12 +150,16 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
onBulkSetDueDate,
|
onBulkSetDueDate,
|
||||||
}) => {
|
}) => {
|
||||||
const { t, ready, isLoading } = useBulkActionTranslations();
|
const { t, ready, isLoading } = useBulkActionTranslations();
|
||||||
|
const dispatch = useDispatch();
|
||||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||||
|
|
||||||
// Get data from Redux store
|
// Get data from Redux store
|
||||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||||
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
||||||
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
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
|
// Performance state management
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@@ -162,6 +177,20 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
dueDate: false,
|
dueDate: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Labels dropdown state
|
||||||
|
const [selectedLabels, setSelectedLabels] = useState<ITaskLabel[]>([]);
|
||||||
|
const [createLabelText, setCreateLabelText] = useState<string>('');
|
||||||
|
const labelsInputRef = useRef<InputRef>(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
|
// Smooth entrance animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (totalSelected > 0) {
|
if (totalSelected > 0) {
|
||||||
@@ -200,6 +229,8 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
})), [phaseList]
|
})), [phaseList]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Menu click handlers
|
// Menu click handlers
|
||||||
const handleStatusMenuClick = useCallback((e: any) => {
|
const handleStatusMenuClick = useCallback((e: any) => {
|
||||||
onBulkStatusChange?.(e.key);
|
onBulkStatusChange?.(e.key);
|
||||||
@@ -213,6 +244,126 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
onBulkPhaseChange?.(e.key);
|
onBulkPhaseChange?.(e.key);
|
||||||
}, [onBulkPhaseChange]);
|
}, [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(() => (
|
||||||
|
<LabelsDropdown
|
||||||
|
labelsList={labelsList || []}
|
||||||
|
themeMode={isDarkMode ? 'dark' : 'light'}
|
||||||
|
createLabelText={createLabelText}
|
||||||
|
selectedLabels={selectedLabels}
|
||||||
|
labelsInputRef={labelsInputRef as React.RefObject<InputRef>}
|
||||||
|
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(() => (
|
||||||
|
<AssigneesDropdown
|
||||||
|
members={members?.data || []}
|
||||||
|
themeMode={isDarkMode ? 'dark' : 'light'}
|
||||||
|
onApply={handleChangeAssignees}
|
||||||
|
onClose={() => setAssigneeDropdownOpen(false)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
), [members?.data, isDarkMode, handleChangeAssignees, t]);
|
||||||
|
|
||||||
// Memoized handlers with loading states
|
// Memoized handlers with loading states
|
||||||
const handleStatusChange = useCallback(async () => {
|
const handleStatusChange = useCallback(async () => {
|
||||||
updateLoadingState('status', true);
|
updateLoadingState('status', true);
|
||||||
@@ -466,13 +617,41 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Change Labels */}
|
{/* Change Labels */}
|
||||||
<ActionButton
|
<Tooltip title={t('ADD_LABELS')} placement="top">
|
||||||
icon={<TagsOutlined />}
|
<Dropdown
|
||||||
tooltip={t('ADD_LABELS')}
|
dropdownRender={() => labelsDropdownContent}
|
||||||
onClick={() => onBulkAddLabels?.([])}
|
trigger={['click']}
|
||||||
loading={loadingStates.labels}
|
placement="top"
|
||||||
isDarkMode={isDarkMode}
|
arrow
|
||||||
/>
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedLabels([]);
|
||||||
|
setCreateLabelText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<TagsOutlined />}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
color: isDarkMode ? '#e5e7eb' : '#374151',
|
||||||
|
border: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px',
|
||||||
|
height: '32px',
|
||||||
|
width: '32px',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'all 0.15s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
loading={loadingStates.labels}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Assign to Me */}
|
{/* Assign to Me */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -484,13 +663,37 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Change Assignees */}
|
{/* Change Assignees */}
|
||||||
<ActionButton
|
<Tooltip title={t('ASSIGN_MEMBERS')} placement="top">
|
||||||
icon={<UsergroupAddOutlined />}
|
<Dropdown
|
||||||
tooltip={t('ASSIGN_MEMBERS')}
|
dropdownRender={() => assigneesDropdownContent}
|
||||||
onClick={() => onBulkAssignMembers?.([])}
|
open={assigneeDropdownOpen}
|
||||||
loading={loadingStates.assignMembers}
|
onOpenChange={onAssigneeDropdownOpenChange}
|
||||||
isDarkMode={isDarkMode}
|
trigger={['click']}
|
||||||
/>
|
placement="top"
|
||||||
|
arrow
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<UsergroupAddOutlined />}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
color: isDarkMode ? '#e5e7eb' : '#374151',
|
||||||
|
border: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px',
|
||||||
|
height: '32px',
|
||||||
|
width: '32px',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'all 0.15s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
loading={loadingStates.assignMembers}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Archive */}
|
{/* Archive */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -520,6 +723,46 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
/>
|
/>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
|
||||||
|
{/* More Options (Create Task Template) - Only for owners/admins */}
|
||||||
|
{isOwnerOrAdmin && (
|
||||||
|
<Tooltip title={t('moreOptions')} placement="top">
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: t('createTaskTemplate'),
|
||||||
|
onClick: handleOpenTemplateDrawer,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
placement="top"
|
||||||
|
arrow
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<MoreOutlined />}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
color: isDarkMode ? '#e5e7eb' : '#374151',
|
||||||
|
border: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px',
|
||||||
|
height: '32px',
|
||||||
|
width: '32px',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'all 0.15s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider
|
<Divider
|
||||||
type="vertical"
|
type="vertical"
|
||||||
style={{
|
style={{
|
||||||
@@ -537,6 +780,20 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
|
{/* Task Template Drawer */}
|
||||||
|
{createPortal(
|
||||||
|
<TaskTemplateDrawer
|
||||||
|
showDrawer={showDrawer}
|
||||||
|
selectedTemplateId={null}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDrawer(false);
|
||||||
|
onClearSelection?.();
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
'create-task-template'
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ import {
|
|||||||
toggleTaskSelection,
|
toggleTaskSelection,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
} from '@/features/task-management/selection.slice';
|
} from '@/features/task-management/selection.slice';
|
||||||
|
import {
|
||||||
|
selectTaskIds,
|
||||||
|
selectTasks,
|
||||||
|
deselectAll as deselectAllBulk,
|
||||||
|
} from '@/features/projects/bulkActions/bulkActionSlice';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
@@ -49,7 +54,6 @@ import OptimizedBulkActionBar from './optimized-bulk-action-bar';
|
|||||||
import VirtualizedTaskList from './virtualized-task-list';
|
import VirtualizedTaskList from './virtualized-task-list';
|
||||||
import { AppDispatch } from '@/app/store';
|
import { AppDispatch } from '@/app/store';
|
||||||
import { shallowEqual } from 'react-redux';
|
import { shallowEqual } from 'react-redux';
|
||||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
|
||||||
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
||||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
import {
|
import {
|
||||||
@@ -73,6 +77,7 @@ import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
|||||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||||
import alertService from '@/services/alerts/alertService';
|
import alertService from '@/services/alerts/alertService';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
@@ -153,7 +158,9 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const tasks = useSelector(taskManagementSelectors.selectAll);
|
const tasks = useSelector(taskManagementSelectors.selectAll);
|
||||||
const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual);
|
const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual);
|
||||||
const currentGrouping = useSelector(selectCurrentGroupingV3, shallowEqual);
|
const currentGrouping = useSelector(selectCurrentGroupingV3, shallowEqual);
|
||||||
const selectedTaskIds = useSelector(selectSelectedTaskIds);
|
// Use bulk action slice for selected tasks instead of selection slice
|
||||||
|
const selectedTaskIds = useSelector((state: RootState) => state.bulkActionReducer.selectedTaskIdsList);
|
||||||
|
const selectedTasks = useSelector((state: RootState) => state.bulkActionReducer.selectedTasks);
|
||||||
const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual);
|
const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual);
|
||||||
const error = useSelector((state: RootState) => state.taskManagement.error);
|
const error = useSelector((state: RootState) => state.taskManagement.error);
|
||||||
|
|
||||||
@@ -403,9 +410,53 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
const handleSelectTask = useCallback(
|
const handleSelectTask = useCallback(
|
||||||
(taskId: string, selected: boolean) => {
|
(taskId: string, selected: boolean) => {
|
||||||
dispatch(toggleTaskSelection(taskId));
|
if (selected) {
|
||||||
|
// Add task to bulk selection
|
||||||
|
const task = tasks.find(t => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
// Convert Task to IProjectTask format for bulk actions
|
||||||
|
const projectTask: IProjectTask = {
|
||||||
|
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 => ({
|
||||||
|
id: assigneeId,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
avatar_url: '',
|
||||||
|
team_member_id: assigneeId,
|
||||||
|
project_member_id: assigneeId,
|
||||||
|
})),
|
||||||
|
labels: task.labels,
|
||||||
|
manual_progress: false, // Default value for Task type
|
||||||
|
created_at: task.createdAt,
|
||||||
|
updated_at: task.updatedAt,
|
||||||
|
sort_order: task.order,
|
||||||
|
};
|
||||||
|
dispatch(selectTasks([...selectedTasks, projectTask]));
|
||||||
|
dispatch(selectTaskIds([...selectedTaskIds, taskId]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove task from bulk selection
|
||||||
|
const updatedTasks = selectedTasks.filter(t => t.id !== taskId);
|
||||||
|
const updatedTaskIds = selectedTaskIds.filter(id => id !== taskId);
|
||||||
|
dispatch(selectTasks(updatedTasks));
|
||||||
|
dispatch(selectTaskIds(updatedTaskIds));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch, selectedTasks, selectedTaskIds, tasks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggleSubtasks = useCallback((taskId: string) => {
|
const handleToggleSubtasks = useCallback((taskId: string) => {
|
||||||
@@ -430,7 +481,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
// Bulk action handlers - implementing real functionality from task-list-bulk-actions-bar
|
// Bulk action handlers - implementing real functionality from task-list-bulk-actions-bar
|
||||||
const handleClearSelection = useCallback(() => {
|
const handleClearSelection = useCallback(() => {
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
@@ -468,7 +519,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -490,7 +541,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -512,7 +563,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -531,7 +582,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.assignToMe(body);
|
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
|
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -563,7 +614,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.assignTasks(body);
|
const res = await taskListBulkActionsApiService.assignTasks(body);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -588,7 +639,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
dispatch(fetchLabels());
|
dispatch(fetchLabels());
|
||||||
@@ -608,7 +659,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
|
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_archive);
|
trackMixpanelEvent(evt_project_task_list_bulk_archive);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -627,7 +678,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
|
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_delete);
|
trackMixpanelEvent(evt_project_task_list_bulk_delete);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const TaskTemplateDrawer = ({
|
|||||||
fetchTemplateData();
|
fetchTemplateData();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Tasks should already have the name property set correctly
|
||||||
setTemplateData({ tasks: selectedTasks });
|
setTemplateData({ tasks: selectedTasks });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ const TaskTemplateDrawer = ({
|
|||||||
open={showDrawer}
|
open={showDrawer}
|
||||||
onClose={onCloseDrawer}
|
onClose={onCloseDrawer}
|
||||||
afterOpenChange={afterOpenChange}
|
afterOpenChange={afterOpenChange}
|
||||||
destroyOnClose={true}
|
destroyOnHidden={true}
|
||||||
footer={
|
footer={
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', justifyContent: 'right' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', justifyContent: 'right' }}>
|
||||||
<Button onClick={onCloseDrawer}>{t('cancelButton')}</Button>
|
<Button onClick={onCloseDrawer}>{t('cancelButton')}</Button>
|
||||||
|
|||||||
@@ -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 = () => {
|
const isOnApply = () => {
|
||||||
if (!createLabelText.trim() && selectedLabels.length === 0) return;
|
if (!createLabelText.trim() && selectedLabels.length === 0) return;
|
||||||
onApply();
|
onApply();
|
||||||
@@ -42,18 +59,17 @@ const LabelsDropdown = ({
|
|||||||
return (
|
return (
|
||||||
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
|
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
{/* Always show the list, filtered by input */}
|
{/* Show filtered labels list */}
|
||||||
{!createLabelText && (
|
<List
|
||||||
<List
|
style={{
|
||||||
style={{
|
padding: 0,
|
||||||
padding: 0,
|
overflow: 'auto',
|
||||||
overflow: 'auto',
|
maxHeight: filteredLabels.length > 10 ? '200px' : 'auto',
|
||||||
maxHeight: labelsList.length > 10 ? '200px' : 'auto', // Set max height if more than 10 labels
|
maxWidth: 250,
|
||||||
maxWidth: 250,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{labelsList.length > 0 && (
|
{filteredLabels.length > 0 ? (
|
||||||
labelsList.map(label => (
|
filteredLabels.map(label => (
|
||||||
<List.Item
|
<List.Item
|
||||||
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
|
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
|
||||||
key={label.id}
|
key={label.id}
|
||||||
@@ -75,30 +91,68 @@ const LabelsDropdown = ({
|
|||||||
</Checkbox>
|
</Checkbox>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
))
|
))
|
||||||
)}
|
) : createLabelText.trim() ? (
|
||||||
</List>
|
<List.Item
|
||||||
)}
|
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
padding: '4px 8px',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{t('noMatchingLabels')}
|
||||||
|
</Typography.Text>
|
||||||
|
</List.Item>
|
||||||
|
) : (
|
||||||
|
<List.Item
|
||||||
|
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
padding: '4px 8px',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{t('noLabels')}
|
||||||
|
</Typography.Text>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
|
||||||
<Flex style={{ paddingTop: 8 }} vertical justify="space-between" gap={8}>
|
<Flex style={{ paddingTop: 8 }} vertical justify="space-between" gap={8}>
|
||||||
<Input
|
<Input
|
||||||
ref={labelsInputRef}
|
ref={labelsInputRef}
|
||||||
value={createLabelText}
|
value={createLabelText}
|
||||||
onChange={e => onCreateLabelTextChange(e.currentTarget.value)}
|
onChange={e => onCreateLabelTextChange(e.currentTarget.value)}
|
||||||
placeholder={t('createLabel')}
|
placeholder={t('searchOrCreateLabel')}
|
||||||
onPressEnter={() => {
|
onPressEnter={() => {
|
||||||
isOnApply();
|
isOnApply();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{createLabelText && (
|
{createLabelText && (
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{t('hitEnterToCreate')}
|
{exactMatch
|
||||||
|
? t('labelExists')
|
||||||
|
: t('hitEnterToCreate')
|
||||||
|
}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
{!createLabelText && (
|
<Button
|
||||||
<Button type="primary" size="small" onClick={isOnApply} style={{ width: '100%' }}>
|
type="primary"
|
||||||
{t('apply')}
|
size="small"
|
||||||
</Button>
|
onClick={isOnApply}
|
||||||
)}
|
style={{ width: '100%' }}
|
||||||
|
disabled={!createLabelText.trim() && selectedLabels.length === 0}
|
||||||
|
>
|
||||||
|
{t('apply')}
|
||||||
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user