feat(enhanced-kanban): enhance EnhancedKanbanGroup with editable section names and status management
- Implemented functionality to edit section names directly within the EnhancedKanbanGroup component, allowing for a more dynamic user experience. - Added unique name generation for sections to prevent duplicates. - Integrated status update and deletion capabilities, enabling users to manage task statuses effectively. - Enhanced UI with new Ant Design components for better interaction and visual feedback during editing and deletion processes.
This commit is contained in:
@@ -7,7 +7,30 @@ import EnhancedKanbanTaskCard from './EnhancedKanbanTaskCard';
|
|||||||
import VirtualizedTaskList from './VirtualizedTaskList';
|
import VirtualizedTaskList from './VirtualizedTaskList';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import './EnhancedKanbanGroup.css';
|
import './EnhancedKanbanGroup.css';
|
||||||
|
import { Badge, Flex, InputRef, MenuProps, Popconfirm } from 'antd';
|
||||||
|
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||||
|
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { DeleteOutlined, ExclamationCircleFilled, EditOutlined, LoadingOutlined, RetweetOutlined, MoreOutlined } from '@ant-design/icons/lib/icons';
|
||||||
|
import { colors } from '@/styles/colors';
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import { Tooltip } from 'antd';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
import { Dropdown } from 'antd';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons/lib/icons';
|
||||||
|
import { deleteSection, IGroupBy, setBoardGroupName } from '@/features/board/board-slice';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
|
||||||
|
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||||
|
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
|
||||||
|
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||||
|
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||||
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
|
import { deleteStatusToggleDrawer, seletedStatusCategory } from '@/features/projects/status/DeleteStatusSlice';
|
||||||
interface EnhancedKanbanGroupProps {
|
interface EnhancedKanbanGroupProps {
|
||||||
group: ITaskListGroup;
|
group: ITaskListGroup;
|
||||||
activeTaskId?: string | null;
|
activeTaskId?: string | null;
|
||||||
@@ -22,7 +45,23 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
|
|||||||
activeTaskId,
|
activeTaskId,
|
||||||
overId
|
overId
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isHover, setIsHover] = useState<boolean>(false);
|
||||||
|
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
|
const [isEditable, setIsEditable] = useState(false);
|
||||||
|
const isProjectManager = useIsProjectManager();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [name, setName] = useState(group.name);
|
||||||
|
const inputRef = useRef<InputRef>(null);
|
||||||
|
const [editName, setEdit] = useState(group.name);
|
||||||
|
const [isEllipsisActive, setIsEllipsisActive] = useState(false);
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||||
|
const { groupBy } = useAppSelector(state => state.boardReducer);
|
||||||
|
const { statusCategories, status } = useAppSelector(state => state.taskStatusReducer);
|
||||||
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
|
const [showNewCard, setShowNewCard] = useState(false);
|
||||||
|
const { t } = useTranslation('kanban-board');
|
||||||
|
|
||||||
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
@@ -99,6 +138,51 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
|
|||||||
transition,
|
transition,
|
||||||
opacity: isGroupDragging ? 0.5 : 1,
|
opacity: isGroupDragging ? 0.5 : 1,
|
||||||
};
|
};
|
||||||
|
const getUniqueSectionName = (baseName: string): string => {
|
||||||
|
// Check if the base name already exists
|
||||||
|
const existingNames = status.map(status => status.name?.toLowerCase());
|
||||||
|
|
||||||
|
if (!existingNames.includes(baseName.toLowerCase())) {
|
||||||
|
return baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the base name exists, add a number suffix
|
||||||
|
let counter = 1;
|
||||||
|
let newName = `${baseName.trim()} (${counter})`;
|
||||||
|
|
||||||
|
while (existingNames.includes(newName.toLowerCase())) {
|
||||||
|
counter++;
|
||||||
|
newName = `${baseName.trim()} (${counter})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newName;
|
||||||
|
};
|
||||||
|
const updateStatus = async (category = group.category_id ?? null) => {
|
||||||
|
if (!category || !projectId || !group.id) return;
|
||||||
|
const sectionName = getUniqueSectionName(name);
|
||||||
|
const body: ITaskStatusUpdateModel = {
|
||||||
|
name: sectionName,
|
||||||
|
project_id: projectId,
|
||||||
|
category_id: category,
|
||||||
|
};
|
||||||
|
const res = await statusApiService.updateStatus(group.id, body, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
dispatch(
|
||||||
|
setBoardGroupName({
|
||||||
|
groupId: group.id,
|
||||||
|
name: sectionName ?? '',
|
||||||
|
colorCode: res.body.color_code ?? '',
|
||||||
|
colorCodeDark: res.body.color_code_dark ?? '',
|
||||||
|
categoryId: category,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
dispatch(fetchStatuses(projectId));
|
||||||
|
setName(sectionName);
|
||||||
|
} else {
|
||||||
|
setName(editName);
|
||||||
|
logger.error('Error updating status', res.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Get the appropriate background color based on theme
|
// Get the appropriate background color based on theme
|
||||||
const headerBackgroundColor = useMemo(() => {
|
const headerBackgroundColor = useMemo(() => {
|
||||||
@@ -108,12 +192,128 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
|
|||||||
return group.color_code || '#f5f5f5';
|
return group.color_code || '#f5f5f5';
|
||||||
}, [themeMode, group.color_code, group.color_code_dark]);
|
}, [themeMode, group.color_code, group.color_code_dark]);
|
||||||
|
|
||||||
|
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const taskName = e.target.value;
|
||||||
|
setName(taskName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = async () => {
|
||||||
|
if (group.name === 'Untitled section') {
|
||||||
|
dispatch(deleteSection({ sectionId: group.id }));
|
||||||
|
}
|
||||||
|
setIsEditable(false);
|
||||||
|
|
||||||
|
if (!projectId || !group.id) return;
|
||||||
|
|
||||||
|
if (groupBy === IGroupBy.STATUS) {
|
||||||
|
await updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupBy === IGroupBy.PHASE) {
|
||||||
|
const body = {
|
||||||
|
id: group.id,
|
||||||
|
name: name,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await phasesApiService.updateNameOfPhase(group.id, body as ITaskPhase, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
|
||||||
|
// dispatch(fetchPhasesByProjectId(projectId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressEnter = () => {
|
||||||
|
setShowNewCard(true);
|
||||||
|
setIsEditable(false);
|
||||||
|
handleBlur();
|
||||||
|
};
|
||||||
|
const handleDeleteSection = async () => {
|
||||||
|
if (!projectId || !group.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (groupBy === IGroupBy.STATUS) {
|
||||||
|
const replacingStatusId = '';
|
||||||
|
const res = await statusApiService.deleteStatus(group.id, projectId, replacingStatusId);
|
||||||
|
if (res.message === 'At least one status should exists under each category.') return
|
||||||
|
if (res.done) {
|
||||||
|
dispatch(deleteSection({ sectionId: group.id }));
|
||||||
|
} else {
|
||||||
|
dispatch(seletedStatusCategory({ id: group.id, name: name, category_id: group.category_id ?? '', message: res.message ?? '' }));
|
||||||
|
dispatch(deleteStatusToggleDrawer());
|
||||||
|
}
|
||||||
|
} else if (groupBy === IGroupBy.PHASE) {
|
||||||
|
const res = await phasesApiService.deletePhaseOption(group.id, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
dispatch(deleteSection({ sectionId: group.id }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting section', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const items: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
width: '100%',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
onClick={() => setIsEditable(true)}
|
||||||
|
>
|
||||||
|
<EditOutlined /> <span>{t('rename')}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
groupBy === IGroupBy.STATUS && {
|
||||||
|
key: '2',
|
||||||
|
icon: <RetweetOutlined />,
|
||||||
|
label: 'Change category',
|
||||||
|
children: statusCategories?.map(status => ({
|
||||||
|
key: status.id,
|
||||||
|
label: (
|
||||||
|
<Flex
|
||||||
|
gap={8}
|
||||||
|
onClick={() => status.id && updateStatus(status.id)}
|
||||||
|
style={group.category_id === status.id ? { fontWeight: 700 } : {}}
|
||||||
|
>
|
||||||
|
<Badge color={status.color_code} />
|
||||||
|
{status.name}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
groupBy !== IGroupBy.PRIORITY && {
|
||||||
|
key: '3',
|
||||||
|
label: (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('deleteConfirmationTitle')}
|
||||||
|
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||||
|
okText={t('deleteConfirmationOk')}
|
||||||
|
cancelText={t('deleteConfirmationCancel')}
|
||||||
|
onConfirm={handleDeleteSection}
|
||||||
|
>
|
||||||
|
<Flex gap={8} align="center" style={{ width: '100%' }}>
|
||||||
|
<DeleteOutlined />
|
||||||
|
{t('delete')}
|
||||||
|
</Flex>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
].filter(Boolean) as MenuProps['items'];
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setRefs}
|
ref={setRefs}
|
||||||
style={style}
|
style={style}
|
||||||
className={`enhanced-kanban-group ${isDraggingOver ? 'drag-over' : ''} ${isGroupDragging ? 'group-dragging' : ''}`}
|
className={`enhanced-kanban-group ${isDraggingOver ? 'drag-over' : ''} ${isGroupDragging ? 'group-dragging' : ''}`}
|
||||||
>
|
>
|
||||||
|
{/* section header */}
|
||||||
<div
|
<div
|
||||||
className="enhanced-kanban-group-header"
|
className="enhanced-kanban-group-header"
|
||||||
style={{
|
style={{
|
||||||
@@ -122,13 +322,108 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
|
|||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
>
|
>
|
||||||
<h3 title={group.name}>{group.name}</h3>
|
{/* <span className="task-count">({group.tasks.length})</span> */}
|
||||||
<span className="task-count">({group.tasks.length})</span>
|
<Flex
|
||||||
{shouldVirtualize && (
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setIsHover(true)}
|
||||||
|
onMouseLeave={() => setIsHover(false)}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
gap={6}
|
||||||
|
align="center"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => {
|
||||||
|
if ((isProjectManager || isOwnerOrAdmin) && group.name !== 'Unmapped') setIsEditable(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
style={{
|
||||||
|
minWidth: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 120,
|
||||||
|
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.tasks.length}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{isLoading && <LoadingOutlined style={{ color: colors.darkGray }} />}
|
||||||
|
{isEditable ? (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={group.name}
|
||||||
|
variant="borderless"
|
||||||
|
style={{
|
||||||
|
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||||
|
}}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onPressEnter={handlePressEnter}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tooltip title={isEllipsisActive ? name : null}>
|
||||||
|
<Typography.Text
|
||||||
|
ellipsis={{
|
||||||
|
tooltip: false,
|
||||||
|
onEllipsis: ellipsed => setIsEllipsisActive(ellipsed),
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
minWidth: 185,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
color: themeMode === 'dark' ? '#383838' : '',
|
||||||
|
display: 'inline-block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Typography.Text>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
shape="circle"
|
||||||
|
style={{ color: themeMode === 'dark' ? '#383838' : '' }}
|
||||||
|
onClick={() => setShowNewCard(true)}
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{(isOwnerOrAdmin || isProjectManager) && name !== 'Unmapped' && (
|
||||||
|
<Dropdown
|
||||||
|
overlayClassName="todo-threedot-dropdown"
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{ items }}
|
||||||
|
placement="bottomLeft"
|
||||||
|
>
|
||||||
|
<Button type="text" size="small" shape="circle">
|
||||||
|
<MoreOutlined
|
||||||
|
style={{
|
||||||
|
rotate: '90deg',
|
||||||
|
fontSize: '25px',
|
||||||
|
color: themeMode === 'dark' ? '#383838' : '',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
{/* <h3 title={group.name} style={{ fontSize: 14, fontWeight: 600, color: themeWiseColor('black', '#1e1e1e', themeMode) }}>{group.name}</h3> */}
|
||||||
|
|
||||||
|
{/* {shouldVirtualize && (
|
||||||
<span className="virtualization-indicator" title="Virtualized for performance">
|
<span className="virtualization-indicator" title="Virtualized for performance">
|
||||||
⚡
|
⚡
|
||||||
</span>
|
</span>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="enhanced-kanban-group-tasks" ref={groupRef}>
|
<div className="enhanced-kanban-group-tasks" ref={groupRef}>
|
||||||
|
|||||||
Reference in New Issue
Block a user