Add three new features to v0.1.5
1. Make parent cards draggable with all subtasks - Parent cards can now be dragged between columns - All descendants are automatically moved with the parent - Added isParent flag to drag/drop dataTransfer 2. Add Expand All / Collapse All buttons to Kanban view - Added global expandedCards state management - New buttons in Kanban header with ChevronsDown/Up icons - Allows quick expansion/collapse of all parent cards 3. Add description field to tasks - Added description textarea to TaskForm component - Added description edit option to TaskMenu component - Description displays in both TreeView and KanbanView - Shows below metadata in italic gray text - Backend already supported description field
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react'
|
||||
import {
|
||||
getProjectTasks,
|
||||
createTask,
|
||||
@@ -27,6 +27,18 @@ const FLAG_COLORS = {
|
||||
pink: 'bg-pink-500'
|
||||
}
|
||||
|
||||
// Helper function to get all descendant tasks recursively
|
||||
function getAllDescendants(taskId, allTasks) {
|
||||
const children = allTasks.filter(t => t.parent_task_id === taskId)
|
||||
let descendants = [...children]
|
||||
|
||||
for (const child of children) {
|
||||
descendants = descendants.concat(getAllDescendants(child.id, allTasks))
|
||||
}
|
||||
|
||||
return descendants
|
||||
}
|
||||
|
||||
// Helper function to get all descendant tasks of a parent in a specific status
|
||||
function getDescendantsInStatus(taskId, allTasks, status) {
|
||||
const children = allTasks.filter(t => t.parent_task_id === taskId)
|
||||
@@ -48,11 +60,19 @@ function hasDescendantsInStatus(taskId, allTasks, status) {
|
||||
return getDescendantsInStatus(taskId, allTasks, status).length > 0
|
||||
}
|
||||
|
||||
function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus }) {
|
||||
function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards }) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [editTitle, setEditTitle] = useState(task.title)
|
||||
|
||||
// Use global expanded state
|
||||
const isExpanded = expandedCards[task.id] || false
|
||||
const toggleExpanded = () => {
|
||||
setExpandedCards(prev => ({
|
||||
...prev,
|
||||
[task.id]: !prev[task.id]
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateTask(task.id, { title: editTitle })
|
||||
@@ -80,13 +100,13 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div
|
||||
draggable={!isEditing && !isParent}
|
||||
onDragStart={(e) => !isParent && onDragStart(e, task)}
|
||||
draggable={!isEditing}
|
||||
onDragStart={(e) => onDragStart(e, task, isParent)}
|
||||
className={`${
|
||||
isParent
|
||||
? 'bg-cyber-darker border-2 border-cyber-orange/50'
|
||||
: 'bg-cyber-darkest border border-cyber-orange/30'
|
||||
} rounded-lg p-3 ${!isParent ? 'cursor-move' : ''} hover:border-cyber-orange/60 transition-all group`}
|
||||
} rounded-lg p-3 cursor-move hover:border-cyber-orange/60 transition-all group`}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div className="flex gap-2">
|
||||
@@ -121,7 +141,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
|
||||
{/* Expand/collapse for parent cards */}
|
||||
{isParent && childrenInColumn.length > 0 && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onClick={toggleExpanded}
|
||||
className="text-cyber-orange hover:text-cyber-orange-bright"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
@@ -172,6 +192,13 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{task.description && (
|
||||
<div className="mt-2 text-xs text-gray-400 italic">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,6 +228,8 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
|
||||
onDragStart={onDragStart}
|
||||
isParent={false}
|
||||
columnStatus={columnStatus}
|
||||
expandedCards={expandedCards}
|
||||
setExpandedCards={setExpandedCards}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -209,7 +238,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
|
||||
)
|
||||
}
|
||||
|
||||
function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver }) {
|
||||
function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards }) {
|
||||
const [showAddTask, setShowAddTask] = useState(false)
|
||||
|
||||
const handleAddTask = async (taskData) => {
|
||||
@@ -218,6 +247,7 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
|
||||
project_id: parseInt(projectId),
|
||||
parent_task_id: null,
|
||||
title: taskData.title,
|
||||
description: taskData.description,
|
||||
status: status.key,
|
||||
tags: taskData.tags,
|
||||
estimated_minutes: taskData.estimated_minutes,
|
||||
@@ -289,11 +319,14 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
|
||||
task={task}
|
||||
allTasks={allTasks}
|
||||
onUpdate={onUpdate}
|
||||
onDragStart={(e, task) => {
|
||||
onDragStart={(e, task, isParent) => {
|
||||
e.dataTransfer.setData('taskId', task.id.toString())
|
||||
e.dataTransfer.setData('isParent', isParent.toString())
|
||||
}}
|
||||
isParent={isParent}
|
||||
columnStatus={status.key}
|
||||
expandedCards={expandedCards}
|
||||
setExpandedCards={setExpandedCards}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -306,6 +339,7 @@ function KanbanView({ projectId }) {
|
||||
const [allTasks, setAllTasks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [expandedCards, setExpandedCards] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks()
|
||||
@@ -323,6 +357,19 @@ function KanbanView({ projectId }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleExpandAll = () => {
|
||||
const parentTasks = allTasks.filter(t => allTasks.some(child => child.parent_task_id === t.id))
|
||||
const newExpandedState = {}
|
||||
parentTasks.forEach(task => {
|
||||
newExpandedState[task.id] = true
|
||||
})
|
||||
setExpandedCards(newExpandedState)
|
||||
}
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
setExpandedCards({})
|
||||
}
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
@@ -330,11 +377,22 @@ function KanbanView({ projectId }) {
|
||||
const handleDrop = async (e, newStatus) => {
|
||||
e.preventDefault()
|
||||
const taskId = parseInt(e.dataTransfer.getData('taskId'))
|
||||
const isParent = e.dataTransfer.getData('isParent') === 'true'
|
||||
|
||||
if (!taskId) return
|
||||
|
||||
try {
|
||||
// Update the dragged task
|
||||
await updateTask(taskId, { status: newStatus })
|
||||
|
||||
// If it's a parent task, update all descendants
|
||||
if (isParent) {
|
||||
const descendants = getAllDescendants(taskId, allTasks)
|
||||
for (const descendant of descendants) {
|
||||
await updateTask(descendant.id, { status: newStatus })
|
||||
}
|
||||
}
|
||||
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`)
|
||||
@@ -351,7 +409,25 @@ function KanbanView({ projectId }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-300 mb-4">Kanban Board (Nested View)</h3>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-semibold text-gray-300">Kanban Board (Nested View)</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleExpandAll}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:bg-cyber-dark transition-colors"
|
||||
>
|
||||
<ChevronsDown size={16} />
|
||||
Expand All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCollapseAll}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:bg-cyber-dark transition-colors"
|
||||
>
|
||||
<ChevronsUp size={16} />
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{STATUSES.map(status => (
|
||||
@@ -363,6 +439,8 @@ function KanbanView({ projectId }) {
|
||||
onUpdate={loadTasks}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
expandedCards={expandedCards}
|
||||
setExpandedCards={setExpandedCards}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ const FLAG_COLORS = [
|
||||
|
||||
function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [tags, setTags] = useState('')
|
||||
const [hours, setHours] = useState('')
|
||||
const [minutes, setMinutes] = useState('')
|
||||
@@ -33,6 +34,7 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
|
||||
|
||||
const taskData = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
tags: tagList && tagList.length > 0 ? tagList : null,
|
||||
estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
|
||||
flag_color: flagColor
|
||||
@@ -56,6 +58,18 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional task description..."
|
||||
rows="3"
|
||||
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Tags (comma-separated)</label>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo } from 'lucide-react'
|
||||
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText } from 'lucide-react'
|
||||
import { updateTask } from '../utils/api'
|
||||
|
||||
const FLAG_COLORS = [
|
||||
@@ -22,6 +22,7 @@ const STATUSES = [
|
||||
function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showTimeEdit, setShowTimeEdit] = useState(false)
|
||||
const [showDescriptionEdit, setShowDescriptionEdit] = useState(false)
|
||||
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
||||
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
||||
const [showStatusEdit, setShowStatusEdit] = useState(false)
|
||||
@@ -32,6 +33,7 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
||||
|
||||
const [editHours, setEditHours] = useState(initialHours)
|
||||
const [editMinutes, setEditMinutes] = useState(initialMinutes)
|
||||
const [editDescription, setEditDescription] = useState(task.description || '')
|
||||
const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '')
|
||||
const menuRef = useRef(null)
|
||||
|
||||
@@ -40,6 +42,7 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
setIsOpen(false)
|
||||
setShowTimeEdit(false)
|
||||
setShowDescriptionEdit(false)
|
||||
setShowTagsEdit(false)
|
||||
setShowFlagEdit(false)
|
||||
setShowStatusEdit(false)
|
||||
@@ -65,6 +68,18 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateDescription = async () => {
|
||||
try {
|
||||
const description = editDescription.trim() || null
|
||||
await updateTask(task.id, { description })
|
||||
setShowDescriptionEdit(false)
|
||||
setIsOpen(false)
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateTags = async () => {
|
||||
try {
|
||||
const tags = editTags
|
||||
@@ -184,6 +199,52 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Description Edit */}
|
||||
{showDescriptionEdit ? (
|
||||
<div className="p-3 border-b border-cyber-orange/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText size={14} className="text-cyber-orange" />
|
||||
<span className="text-sm text-gray-300">Description</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Task description..."
|
||||
rows="4"
|
||||
className="w-full px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange resize-y"
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleUpdateDescription}
|
||||
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDescriptionEdit(false)}
|
||||
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowDescriptionEdit(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>Edit Description</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Tags Edit */}
|
||||
{showTagsEdit ? (
|
||||
<div className="p-3 border-b border-cyber-orange/20">
|
||||
|
||||
@@ -80,6 +80,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
project_id: parseInt(projectId),
|
||||
parent_task_id: task.id,
|
||||
title: taskData.title,
|
||||
description: taskData.description,
|
||||
status: 'backlog',
|
||||
tags: taskData.tags,
|
||||
estimated_minutes: taskData.estimated_minutes,
|
||||
@@ -187,6 +188,13 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{task.description && (
|
||||
<div className="mt-2 text-xs text-gray-400 italic">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -266,6 +274,7 @@ function TreeView({ projectId }) {
|
||||
project_id: parseInt(projectId),
|
||||
parent_task_id: null,
|
||||
title: taskData.title,
|
||||
description: taskData.description,
|
||||
status: 'backlog',
|
||||
tags: taskData.tags,
|
||||
estimated_minutes: taskData.estimated_minutes,
|
||||
|
||||
Reference in New Issue
Block a user