This commit is contained in:
chamikaJ
2025-04-17 18:28:54 +05:30
parent f583291d8a
commit 8825b0410a
2837 changed files with 241385 additions and 127578 deletions

View File

@@ -0,0 +1,91 @@
import { Button, Card, Checkbox, Flex, List, Typography } from 'antd';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
import { useEffect, useState } from 'react';
interface AssigneesDropdownProps {
members: ITeamMemberViewModel[];
themeMode: string;
onApply: (selectedAssignees: ITeamMemberViewModel[]) => void;
onClose: () => void;
t: (key: string) => string;
}
const AssigneesDropdown = ({ members, themeMode, onApply, onClose, t }: AssigneesDropdownProps) => {
const [selectedAssignees, setSelectedAssignees] = useState<ITeamMemberViewModel[]>([]);
const handleAssigneeChange = (e: CheckboxChangeEvent, member: ITeamMemberViewModel) => {
if (e.target.checked) {
setSelectedAssignees(prev => [...prev, member]);
} else {
setSelectedAssignees(prev => prev.filter(m => m.id !== member.id));
}
};
const handleClose = () => {
setSelectedAssignees([]);
onClose();
};
return (
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
<Flex vertical>
<List style={{ padding: 0, height: 250, overflow: 'auto' }}>
{members?.map(member => (
<List.Item
className={`${themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'} ${member.pending_invitation ? 'cursor-not-allowed' : ''}`}
key={member.id}
style={{
display: 'flex',
gap: 8,
justifyContent: 'flex-start',
padding: '4px 8px',
border: 'none',
cursor: 'pointer',
}}
>
<Checkbox
disabled={member.pending_invitation}
checked={selectedAssignees.some(a => a.id === member.id)}
onChange={e => handleAssigneeChange(e, member)}
>
<Flex align="center">
<SingleAvatar
avatarUrl={member.avatar_url}
name={member.name}
email={member.email}
/>
<Flex vertical>
<Typography.Text>{member.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{member.email}&nbsp;
{member.pending_invitation && (
<Typography.Text type="danger" style={{ fontSize: 10 }}>
({t('pendingInvitation')})
</Typography.Text>
)}
</Typography.Text>
</Flex>
</Flex>
</Checkbox>
</List.Item>
))}
</List>
<Button
type="primary"
size="small"
style={{ width: '100%' }}
onClick={() => {
handleClose();
onApply(selectedAssignees);
}}
>
{t('apply')}
</Button>
</Flex>
</Card>
);
};
export default AssigneesDropdown;

View File

@@ -0,0 +1,108 @@
import { Badge, Button, Card, Checkbox, Empty, Flex, Input, List, Typography } from 'antd';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { InputRef } from 'antd/es/input';
import { useEffect, useMemo } from 'react'; // Add useMemo for filtering
interface LabelsDropdownProps {
labelsList: ITaskLabel[];
themeMode: string;
createLabelText: string;
selectedLabels: ITaskLabel[];
labelsInputRef: React.RefObject<InputRef>;
onLabelChange: (e: CheckboxChangeEvent, label: ITaskLabel) => void;
onCreateLabelTextChange: (value: string) => void;
onApply: () => void;
t: (key: string) => string;
loading: boolean;
}
const LabelsDropdown = ({
labelsList,
themeMode,
createLabelText,
selectedLabels,
labelsInputRef,
onLabelChange,
onCreateLabelTextChange,
onApply,
loading,
t,
}: LabelsDropdownProps) => {
useEffect(() => {
if (labelsInputRef.current) {
labelsInputRef.current.focus();
}
}, []);
const isOnApply = () => {
if (!createLabelText.trim() && selectedLabels.length === 0) return;
onApply();
};
return (
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
<Flex vertical>
{/* Always show the list, filtered by input */}
{!createLabelText && (
<List
style={{
padding: 0,
overflow: 'auto',
maxHeight: labelsList.length > 10 ? '200px' : 'auto', // Set max height if more than 10 labels
maxWidth: 250,
}}
>
{labelsList.length > 0 && (
labelsList.map(label => (
<List.Item
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
key={label.id}
style={{
display: 'flex',
gap: 8,
justifyContent: 'flex-start',
padding: '4px 8px',
border: 'none',
cursor: 'pointer',
}}
>
<Checkbox
id={label.id}
checked={selectedLabels.some(l => l.id === label.id)}
onChange={e => onLabelChange(e, label)}
>
<Badge color={label.color_code} text={label.name} />
</Checkbox>
</List.Item>
))
)}
</List>
)}
<Flex style={{ paddingTop: 8 }} vertical justify="space-between" gap={8}>
<Input
ref={labelsInputRef}
value={createLabelText}
onChange={e => onCreateLabelTextChange(e.currentTarget.value)}
placeholder={t('createLabel')}
onPressEnter={() => {
isOnApply();
}}
/>
{createLabelText && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('hitEnterToCreate')}
</Typography.Text>
)}
{!createLabelText && (
<Button type="primary" size="small" onClick={isOnApply} style={{ width: '100%' }}>
{t('apply')}
</Button>
)}
</Flex>
</Flex>
</Card>
);
};
export default LabelsDropdown;

View File

@@ -0,0 +1,74 @@
.bulk-actions {
position: fixed;
z-index: 2;
background: #252628;
bottom: 30px;
left: 0;
right: 0;
width: fit-content;
margin: auto;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 25px;
padding: 0 25px;
box-shadow:
0 0 0 1px #edeae9,
0 5px 20px 0 rgba(109, 110, 111, 0.08);
transition: transform 0.2s ease-in;
opacity: 0;
transform: translateY(200%);
.bulk-actions-inner {
&,
button,
nz-select {
color: #fff;
}
}
[nz-icon] {
font-size: 16px;
}
&.open {
transition: transform 0.2s ease-in;
opacity: 1;
transform: translateY(0);
}
.bulk-actions-input-container {
position: absolute;
top: -40px;
width: 100%;
left: 0;
right: 0;
}
}
.disable {
position: relative;
&:after {
position: absolute;
content: "";
background: #e7e7e769;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
}
}
.li-custom * {
min-height: 32px !important;
height: 32px !important;
padding: 5px 12px;
}
.disabled {
opacity: 0.5;
pointer-events: none;
}

View File

@@ -0,0 +1,576 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge, Dropdown, Flex, Tooltip, Button, InputRef, CheckboxChangeEvent } from 'antd/es';
import {
RetweetOutlined,
TagsOutlined,
UserAddOutlined,
UsergroupAddOutlined,
InboxOutlined,
DeleteOutlined,
MoreOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { colors } from '@/styles/colors';
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
import {
evt_project_task_list_bulk_archive,
evt_project_task_list_bulk_assign_me,
evt_project_task_list_bulk_assign_members,
evt_project_task_list_bulk_change_phase,
evt_project_task_list_bulk_change_priority,
evt_project_task_list_bulk_change_status,
evt_project_task_list_bulk_delete,
evt_project_task_list_bulk_update_labels,
} from '@/shared/worklenz-analytics-events';
import {
IBulkTasksLabelsRequest,
IBulkTasksPhaseChangeRequest,
IBulkTasksPriorityChangeRequest,
IBulkTasksStatusChangeRequest,
} from '@/types/tasks/bulk-action-bar.types';
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import './task-list-bulk-actions-bar.css';
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
import { createPortal } from 'react-dom';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
import AssigneesDropdown from './components/AssigneesDropdown';
import LabelsDropdown from './components/LabelsDropdown';
import { sortTeamMembers } from '@/utils/sort-team-members';
import logger from '@/utils/errorLogger';
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import { useAuthService } from '@/hooks/useAuth';
import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import alertService from '@/services/alerts/alertService';
interface ITaskAssignee {
id: string;
name?: string;
email?: string;
avatar_url?: string;
team_member_id: string;
project_member_id: string;
}
const TaskListBulkActionsBar = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('tasks/task-table-bulk-actions');
const { trackMixpanelEvent } = useMixpanelTracking();
// Add permission hooks near other hooks
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
// loading state
const [loading, setLoading] = useState(false);
const [updatingLabels, setUpdatingLabels] = useState(false);
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
const [updatingAssignees, setUpdatingAssignees] = useState(false);
const [updatingArchive, setUpdatingArchive] = useState(false);
const [updatingDelete, setUpdatingDelete] = useState(false);
// Selectors
const { selectedTaskIdsList } = useAppSelector(state => state.bulkActionReducer);
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 projectId = useAppSelector(state => state.projectReducer.projectId);
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
const archived = useAppSelector(state => state.taskReducer.archived);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const labelsInputRef = useRef<InputRef>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const [createLabelText, setCreateLabelText] = useState<string>('');
const [teamMembersSorted, setTeamMembersSorted] = useState<ITeamMembersViewModel>({
data: [],
total: 0,
});
const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false);
const [showDrawer, setShowDrawer] = useState(false);
const [selectedLabels, setSelectedLabels] = useState<ITaskLabel[]>([]);
// Add refs for tooltip elements
const changeStatusRef = useRef(null);
const changeLabelRef = useRef(null);
const assignToMeRef = useRef(null);
const changeAssigneesRef = useRef(null);
const archiveRef = useRef(null);
const deleteRef = useRef(null);
const moreOptionsRef = useRef(null);
const deselectAllRef = useRef(null);
// Handlers
const handleChangeStatus = async (status: ITaskStatus) => {
if (!status.id || !projectId) return;
try {
setLoading(true);
const body: IBulkTasksStatusChangeRequest = {
tasks: selectedTaskIdsList,
status_id: status.id,
};
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
}
for (const it of selectedTaskIdsList) {
const canContinue = await checkTaskDependencyStatus(it, status.id);
if (!canContinue) {
if (selectedTaskIdsList.length > 1) {
alertService.warning(
'Incomplete Dependencies!',
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
);
} else {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
}
return;
}
}
} catch (error) {
logger.error('Error changing status:', error);
} finally {
setLoading(false);
}
};
const handleChangePriority = async (priority: ITaskPriority) => {
if (!priority.id || !projectId) return;
try {
setLoading(true);
const body: IBulkTasksPriorityChangeRequest = {
tasks: selectedTaskIdsList,
priority_id: priority.id,
};
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
}
} catch (error) {
logger.error('Error changing priority:', error);
} finally {
setLoading(false);
}
};
const handleChangePhase = async (phase: ITaskPhase) => {
if (!phase.id || !projectId) return;
try {
setLoading(true);
const body: IBulkTasksPhaseChangeRequest = {
tasks: selectedTaskIdsList,
phase_id: phase.id,
};
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
}
} catch (error) {
logger.error('Error changing phase:', error);
} finally {
setLoading(false);
}
};
const handleAssignToMe = async () => {
if (!projectId) return;
try {
setUpdatingAssignToMe(true);
const body = {
tasks: selectedTaskIdsList,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.assignToMe(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
}
} catch (error) {
logger.error('Error assigning to me:', error);
} finally {
setUpdatingAssignToMe(false);
}
};
const handleArchive = async () => {
if (!projectId) return;
try {
setUpdatingArchive(true);
const body = {
tasks: selectedTaskIdsList,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_archive);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
}
} catch (error) {
logger.error('Error archiving tasks:', error);
} finally {
setUpdatingArchive(false);
}
};
const handleChangeAssignees = async (selectedAssignees: ITeamMemberViewModel[]) => {
if (!projectId) return;
try {
setUpdatingAssignees(true);
const body = {
tasks: selectedTaskIdsList,
project_id: projectId,
members: selectedAssignees.map(member => ({
id: member.id,
name: member.name,
email: member.email,
avatar_url: member.avatar_url,
})) as ITaskAssignee[],
};
const res = await taskListBulkActionsApiService.assignTasks(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
}
} catch (error) {
logger.error('Error assigning tasks:', error);
} finally {
setUpdatingAssignees(false);
}
};
const handleDelete = async () => {
if (!projectId) return;
try {
setUpdatingDelete(true);
const body = {
tasks: selectedTaskIdsList,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_delete);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
}
} catch (error) {
logger.error('Error deleting tasks:', error);
} finally {
setUpdatingDelete(false);
}
};
// Menu Generators
const getChangeOptionsMenu = () => [
{
key: '1',
label: t('status'),
children: statusList.map(status => ({
key: status.id,
onClick: () => handleChangeStatus(status),
label: <Badge color={status.color_code} text={status.name} />,
})),
},
{
key: '2',
label: t('priority'),
children: priorityList.map(priority => ({
key: priority.id,
onClick: () => handleChangePriority(priority),
label: <Badge color={priority.color_code} text={priority.name} />,
})),
},
{
key: '3',
label: t('phase'),
children: phaseList.map(phase => ({
key: phase.id,
onClick: () => handleChangePhase(phase),
label: <Badge color={phase.color_code} text={phase.name} />,
})),
},
];
const getLabel = () => {
const word = selectedTaskIdsList.length < 2 ? t('taskSelected') : t('tasksSelected');
return `${selectedTaskIdsList.length} ${word}`;
};
useEffect(() => {
if (members?.data && assigneeDropdownOpen) {
let sortedMembers = sortTeamMembers(members.data);
setTeamMembersSorted({ data: sortedMembers, total: members.total });
}
}, [assigneeDropdownOpen, members?.data]);
const getAssigneesMenu = () => {
return (
<AssigneesDropdown
members={teamMembersSorted?.data || []}
themeMode={themeMode}
onApply={handleChangeAssignees}
onClose={() => setAssigneeDropdownOpen(false)}
t={t}
/>
);
};
const buttonStyle = { background: colors.transparent, color: colors.white };
const handleLabelChange = (e: CheckboxChangeEvent, label: ITaskLabel) => {
if (e.target.checked) {
setSelectedLabels(prev => [...prev, label]);
} else {
setSelectedLabels(prev => prev.filter(l => l.id !== label.id));
}
};
const applyLabels = async () => {
if (!projectId) return;
try {
setUpdatingLabels(true);
const body: IBulkTasksLabelsRequest = {
tasks: selectedTaskIdsList,
labels: selectedLabels,
text:
selectedLabels.length > 0
? null
: createLabelText.trim() !== ''
? createLabelText.trim()
: null,
};
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
dispatch(fetchLabels()); // Fallback: refetch all labels
dispatch(fetchTaskGroups(projectId));
setCreateLabelText('');
setSelectedLabels([]);
}
} catch (error) {
logger.error('Error updating labels:', error);
} finally {
setUpdatingLabels(false);
}
};
const labelsDropdownContent = (
<LabelsDropdown
labelsList={labelsList}
themeMode={themeMode}
createLabelText={createLabelText}
selectedLabels={selectedLabels}
labelsInputRef={labelsInputRef as React.RefObject<InputRef>}
onLabelChange={handleLabelChange}
onCreateLabelTextChange={value => setCreateLabelText(value)}
onApply={applyLabels}
t={t}
loading={updatingLabels}
/>
);
const onAssigneeDropdownOpenChange = (open: boolean) => {
if (!open) {
setAssigneeDropdownOpen(false);
} else {
setAssigneeDropdownOpen(true);
}
};
return (
<div className={`bulk-actions ${selectedTaskIdsList.length > 0 ? 'open' : ''}`}>
<Flex className="bulk-actions-inner" align="center" justify="center" gap={12}>
<Flex>
<span style={{ fontSize: 14, fontWeight: 500 }}>{getLabel()}</span>
</Flex>
<Flex align="center">
<Tooltip title={t('changeStatus')} getPopupContainer={() => changeStatusRef.current!}>
<div ref={changeStatusRef}>
<Dropdown
menu={{ items: getChangeOptionsMenu() }}
placement="bottom"
arrow
trigger={['click']}
getPopupContainer={() => changeStatusRef.current!}
>
<Button
icon={<RetweetOutlined />}
className="borderless-icon-btn"
style={buttonStyle}
loading={loading}
/>
</Dropdown>
</div>
</Tooltip>
<Tooltip title={t('changeLabel')} getPopupContainer={() => changeLabelRef.current!}>
<div ref={changeLabelRef}>
<Dropdown
dropdownRender={() => labelsDropdownContent}
placement="top"
arrow
trigger={['click']}
destroyPopupOnHide
onOpenChange={value => {
if (!value) {
setSelectedLabels([]);
}
}}
getPopupContainer={() => changeLabelRef.current!}
>
<Button
icon={<TagsOutlined />}
className="borderless-icon-btn"
style={buttonStyle}
loading={updatingLabels}
/>
</Dropdown>
</div>
</Tooltip>
<Tooltip title={t('assignToMe')} getPopupContainer={() => assignToMeRef.current!}>
<div ref={assignToMeRef}>
<Button
icon={<UserAddOutlined />}
className="borderless-icon-btn"
style={buttonStyle}
onClick={handleAssignToMe}
loading={updatingAssignToMe}
/>
</div>
</Tooltip>
<Tooltip
title={t('changeAssignees')}
getPopupContainer={() => changeAssigneesRef.current!}
>
<div ref={changeAssigneesRef}>
<Dropdown
dropdownRender={getAssigneesMenu}
open={assigneeDropdownOpen}
onOpenChange={onAssigneeDropdownOpenChange}
placement="top"
arrow
trigger={['click']}
getPopupContainer={() => changeAssigneesRef.current!}
>
<Button
icon={<UsergroupAddOutlined />}
className="borderless-icon-btn"
style={buttonStyle}
loading={updatingAssignees}
/>
</Dropdown>
</div>
</Tooltip>
<Tooltip
title={archived ? t('unarchive') : t('archive')}
getPopupContainer={() => archiveRef.current!}
>
<div ref={archiveRef}>
<Button
icon={<InboxOutlined />}
className="borderless-icon-btn"
style={buttonStyle}
onClick={handleArchive}
loading={updatingArchive}
/>
</div>
</Tooltip>
<Tooltip title={t('delete')} getPopupContainer={() => deleteRef.current!}>
<div ref={deleteRef}>
<Button
icon={<DeleteOutlined />}
className="borderless-icon-btn"
style={buttonStyle}
onClick={handleDelete}
loading={updatingDelete}
/>
</div>
</Tooltip>
</Flex>
{isOwnerOrAdmin && (
<Tooltip title={t('moreOptions')} getPopupContainer={() => moreOptionsRef.current!}>
<div ref={moreOptionsRef}>
<Dropdown
trigger={['click']}
menu={{
items: [
{
key: '1',
label: t('createTaskTemplate'),
onClick: () => setShowDrawer(true),
},
],
}}
>
<Button
icon={<MoreOutlined />}
className="borderless-icon-btn"
style={buttonStyle}
/>
</Dropdown>
</div>
</Tooltip>
)}
<Tooltip title={t('deselectAll')} getPopupContainer={() => deselectAllRef.current!}>
<div ref={deselectAllRef}>
<Button
icon={<CloseCircleOutlined />}
onClick={() => dispatch(deselectAll())}
className="borderless-icon-btn"
style={buttonStyle}
/>
</div>
</Tooltip>
{createPortal(
<TaskTemplateDrawer
showDrawer={showDrawer}
selectedTemplateId={null}
onClose={() => {
setShowDrawer(false);
dispatch(deselectAll());
}}
/>,
document.body,
'create-task-template'
)}
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-modal')}
{createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')}
</Flex>
</div>
);
};
export default TaskListBulkActionsBar;