Add file search to the edit box
This commit is contained in:
@@ -1,71 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface TaskCreateDialogProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreateTask: (title: string, description: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function TaskCreateDialog({ isOpen, onOpenChange, onCreateTask }: TaskCreateDialogProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return
|
||||
|
||||
await onCreateTask(title, description)
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter task title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter task description (optional)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate}>Create Task</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import type { TaskStatus } from 'shared/types'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface TaskEditDialogProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
task: Task | null
|
||||
onUpdateTask: (title: string, description: string, status: TaskStatus) => Promise<void>
|
||||
}
|
||||
|
||||
export function TaskEditDialog({ isOpen, onOpenChange, task, onUpdateTask }: TaskEditDialogProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [status, setStatus] = useState<TaskStatus>('todo')
|
||||
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
setTitle(task.title)
|
||||
setDescription(task.description || '')
|
||||
setStatus(task.status)
|
||||
}
|
||||
}, [task])
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!title.trim()) return
|
||||
|
||||
await onUpdateTask(title, description, status)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-title">Title</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter task title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter task description (optional)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-status">Status</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => setStatus(value as TaskStatus)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="inprogress">In Progress</SelectItem>
|
||||
<SelectItem value="inreview">In Review</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdate}>Update Task</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
181
frontend/src/components/tasks/TaskFormDialog.tsx
Normal file
181
frontend/src/components/tasks/TaskFormDialog.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FileSearchTextarea } from '@/components/ui/file-search-textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import type { TaskStatus } from 'shared/types'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface TaskFormDialogProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
task?: Task | null // Optional for create mode
|
||||
projectId?: string // For file search functionality
|
||||
onCreateTask?: (title: string, description: string) => Promise<void>
|
||||
onUpdateTask?: (title: string, description: string, status: TaskStatus) => Promise<void>
|
||||
}
|
||||
|
||||
export function TaskFormDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
task,
|
||||
projectId,
|
||||
onCreateTask,
|
||||
onUpdateTask
|
||||
}: TaskFormDialogProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [status, setStatus] = useState<TaskStatus>('todo')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const isEditMode = Boolean(task)
|
||||
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
// Edit mode - populate with existing task data
|
||||
setTitle(task.title)
|
||||
setDescription(task.description || '')
|
||||
setStatus(task.status)
|
||||
} else {
|
||||
// Create mode - reset to defaults
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setStatus('todo')
|
||||
}
|
||||
}, [task, isOpen])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
if (isEditMode && onUpdateTask) {
|
||||
await onUpdateTask(title, description, status)
|
||||
} else if (!isEditMode && onCreateTask) {
|
||||
await onCreateTask(title, description)
|
||||
}
|
||||
|
||||
// Reset form on successful creation
|
||||
if (!isEditMode) {
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setStatus('todo')
|
||||
}
|
||||
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset form state when canceling
|
||||
if (task) {
|
||||
setTitle(task.title)
|
||||
setDescription(task.description || '')
|
||||
setStatus(task.status)
|
||||
} else {
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setStatus('todo')
|
||||
}
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? 'Edit Task' : 'Create New Task'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="task-title">Title</Label>
|
||||
<Input
|
||||
id="task-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter task title"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="task-description">Description</Label>
|
||||
<FileSearchTextarea
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Enter task description (optional). Type @ to search files."
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditMode && (
|
||||
<div>
|
||||
<Label htmlFor="task-status">Status</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => setStatus(value as TaskStatus)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="inprogress">In Progress</SelectItem>
|
||||
<SelectItem value="inreview">In Review</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !title.trim()}
|
||||
>
|
||||
{isSubmitting
|
||||
? (isEditMode ? 'Updating...' : 'Creating...')
|
||||
: (isEditMode ? 'Update Task' : 'Create Task')
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export { TaskCreateDialog } from './TaskCreateDialog'
|
||||
export { TaskEditDialog } from './TaskEditDialog'
|
||||
export { TaskFormDialog } from './TaskFormDialog'
|
||||
export { TaskCard } from './TaskCard'
|
||||
export { TaskKanbanBoard } from './TaskKanbanBoard'
|
||||
|
||||
232
frontend/src/components/ui/file-search-textarea.tsx
Normal file
232
frontend/src/components/ui/file-search-textarea.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useState, useRef, useEffect, KeyboardEvent } from 'react'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { makeRequest } from '@/lib/api'
|
||||
|
||||
interface FileSearchResult {
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean
|
||||
data: T | null
|
||||
message: string | null
|
||||
}
|
||||
|
||||
interface FileSearchTextareaProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
export function FileSearchTextarea({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 3,
|
||||
disabled = false,
|
||||
className,
|
||||
projectId
|
||||
}: FileSearchTextareaProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<FileSearchResult[]>([])
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
|
||||
const [atSymbolPosition, setAtSymbolPosition] = useState(-1)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Search for files when query changes
|
||||
useEffect(() => {
|
||||
if (!searchQuery || !projectId || searchQuery.length < 1) {
|
||||
setSearchResults([])
|
||||
setShowDropdown(false)
|
||||
return
|
||||
}
|
||||
|
||||
const searchFiles = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await makeRequest(
|
||||
`/api/projects/${projectId}/search?q=${encodeURIComponent(searchQuery)}`
|
||||
)
|
||||
|
||||
if (response.ok) {
|
||||
const result: ApiResponse<FileSearchResult[]> = await response.json()
|
||||
if (result.success && result.data) {
|
||||
setSearchResults(result.data)
|
||||
setShowDropdown(true)
|
||||
setSelectedIndex(-1)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to search files:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const debounceTimer = setTimeout(searchFiles, 300)
|
||||
return () => clearTimeout(debounceTimer)
|
||||
}, [searchQuery, projectId])
|
||||
|
||||
// Handle text changes and detect @ symbol
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
const newCursorPosition = e.target.selectionStart || 0
|
||||
|
||||
onChange(newValue)
|
||||
|
||||
// Check if @ was just typed
|
||||
const textBeforeCursor = newValue.slice(0, newCursorPosition)
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||
|
||||
if (lastAtIndex !== -1) {
|
||||
// Check if there's no space after the @ (still typing the search query)
|
||||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1)
|
||||
const hasSpace = textAfterAt.includes(' ') || textAfterAt.includes('\n')
|
||||
|
||||
if (!hasSpace) {
|
||||
setAtSymbolPosition(lastAtIndex)
|
||||
setSearchQuery(textAfterAt)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid @ context, hide dropdown
|
||||
setShowDropdown(false)
|
||||
setSearchQuery('')
|
||||
setAtSymbolPosition(-1)
|
||||
}
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!showDropdown || searchResults.length === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev =>
|
||||
prev < searchResults.length - 1 ? prev + 1 : 0
|
||||
)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev =>
|
||||
prev > 0 ? prev - 1 : searchResults.length - 1
|
||||
)
|
||||
break
|
||||
case 'Enter':
|
||||
if (selectedIndex >= 0) {
|
||||
e.preventDefault()
|
||||
selectFile(searchResults[selectedIndex])
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setShowDropdown(false)
|
||||
setSearchQuery('')
|
||||
setAtSymbolPosition(-1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Select a file and insert it into the text
|
||||
const selectFile = (file: FileSearchResult) => {
|
||||
if (atSymbolPosition === -1) return
|
||||
|
||||
const beforeAt = value.slice(0, atSymbolPosition)
|
||||
const afterQuery = value.slice(atSymbolPosition + 1 + searchQuery.length)
|
||||
const newValue = beforeAt + file.path + afterQuery
|
||||
|
||||
onChange(newValue)
|
||||
setShowDropdown(false)
|
||||
setSearchQuery('')
|
||||
setAtSymbolPosition(-1)
|
||||
|
||||
// Focus back to textarea
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
const newCursorPos = atSymbolPosition + file.path.length
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// Calculate dropdown position
|
||||
const getDropdownPosition = () => {
|
||||
if (!textareaRef.current || atSymbolPosition === -1) return { top: 0, left: 0 }
|
||||
|
||||
const textBeforeAt = value.slice(0, atSymbolPosition)
|
||||
const lines = textBeforeAt.split('\n')
|
||||
const currentLine = lines.length - 1
|
||||
const charInLine = lines[lines.length - 1].length
|
||||
|
||||
// Rough calculation - this is an approximation
|
||||
const lineHeight = 20
|
||||
const charWidth = 8
|
||||
const top = (currentLine + 1) * lineHeight + 10
|
||||
const left = charWidth * charInLine
|
||||
|
||||
return { top, left }
|
||||
}
|
||||
|
||||
const dropdownPosition = getDropdownPosition()
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
/>
|
||||
|
||||
{showDropdown && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-50 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto min-w-64"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="p-2 text-sm text-gray-500">Searching...</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-2 text-sm text-gray-500">No files found</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{searchResults.map((file, index) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className={`px-3 py-2 cursor-pointer text-sm ${
|
||||
index === selectedIndex
|
||||
? 'bg-blue-50 text-blue-900'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => selectFile(file)}
|
||||
>
|
||||
<div className="font-medium truncate">{file.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{file.path}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { makeRequest } from "@/lib/api";
|
||||
import { TaskCreateDialog } from "@/components/tasks/TaskCreateDialog";
|
||||
import { TaskEditDialog } from "@/components/tasks/TaskEditDialog";
|
||||
import { TaskFormDialog } from "@/components/tasks/TaskFormDialog";
|
||||
|
||||
import { TaskKanbanBoard } from "@/components/tasks/TaskKanbanBoard";
|
||||
import type { TaskStatus, TaskWithAttemptStatus } from "shared/types";
|
||||
@@ -34,9 +33,8 @@ export function ProjectTasks() {
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isTaskDialogOpen, setIsTaskDialogOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
@@ -177,7 +175,12 @@ export function ProjectTasks() {
|
||||
|
||||
const handleEditTask = (task: Task) => {
|
||||
setEditingTask(task);
|
||||
setIsEditDialogOpen(true);
|
||||
setIsTaskDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateNewTask = () => {
|
||||
setEditingTask(null);
|
||||
setIsTaskDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleViewTaskDetails = (task: Task) => {
|
||||
@@ -266,16 +269,19 @@ export function ProjectTasks() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Button onClick={handleCreateNewTask}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Task
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TaskCreateDialog
|
||||
isOpen={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
<TaskFormDialog
|
||||
isOpen={isTaskDialogOpen}
|
||||
onOpenChange={setIsTaskDialogOpen}
|
||||
task={editingTask}
|
||||
projectId={projectId}
|
||||
onCreateTask={handleCreateTask}
|
||||
onUpdateTask={handleUpdateTask}
|
||||
/>
|
||||
|
||||
{/* Tasks View */}
|
||||
@@ -287,7 +293,7 @@ export function ProjectTasks() {
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
onClick={handleCreateNewTask}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Task
|
||||
@@ -304,12 +310,7 @@ export function ProjectTasks() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<TaskEditDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
task={editingTask}
|
||||
onUpdateTask={handleUpdateTask}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -15,6 +13,7 @@ import {
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ArrowLeft, FileText } from "lucide-react";
|
||||
import { makeRequest } from "@/lib/api";
|
||||
import { TaskFormDialog } from "@/components/tasks/TaskFormDialog";
|
||||
import type {
|
||||
TaskStatus,
|
||||
TaskAttempt,
|
||||
@@ -69,12 +68,7 @@ export function TaskDetailsPage() {
|
||||
const [stoppingAttempt, setStoppingAttempt] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Edit mode state
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState("");
|
||||
const [editedDescription, setEditedDescription] = useState("");
|
||||
const [editedStatus, setEditedStatus] = useState<TaskStatus>("todo");
|
||||
const [savingTask, setSavingTask] = useState(false);
|
||||
const [isTaskDialogOpen, setIsTaskDialogOpen] = useState(false);
|
||||
|
||||
// Check if the selected attempt is currently running (latest activity is "inprogress" or "init")
|
||||
const isAttemptRunning =
|
||||
@@ -106,11 +100,6 @@ export function TaskDetailsPage() {
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
fetchTaskAttempts(task.id);
|
||||
// Initialize edit state with current task values
|
||||
setEditedTitle(task.title);
|
||||
setEditedDescription(task.description || "");
|
||||
setEditedStatus(task.status);
|
||||
setIsEditMode(false);
|
||||
}
|
||||
}, [task]);
|
||||
|
||||
@@ -232,11 +221,12 @@ export function TaskDetailsPage() {
|
||||
fetchAttemptActivities(attempt.id);
|
||||
};
|
||||
|
||||
const saveTaskChanges = async () => {
|
||||
|
||||
|
||||
const handleUpdateTaskFromDialog = async (title: string, description: string, status: TaskStatus) => {
|
||||
if (!task || !projectId) return;
|
||||
|
||||
try {
|
||||
setSavingTask(true);
|
||||
const response = await makeRequest(
|
||||
`/api/projects/${projectId}/tasks/${task.id}`,
|
||||
{
|
||||
@@ -245,41 +235,29 @@ export function TaskDetailsPage() {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: editedTitle,
|
||||
description: editedDescription || null,
|
||||
status: editedStatus,
|
||||
title,
|
||||
description: description || null,
|
||||
status,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
setIsEditMode(false);
|
||||
// Update the local task state
|
||||
setTask({
|
||||
...task,
|
||||
title: editedTitle,
|
||||
description: editedDescription || null,
|
||||
status: editedStatus,
|
||||
title,
|
||||
description: description || null,
|
||||
status,
|
||||
});
|
||||
} else {
|
||||
setError("Failed to save task changes");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to save task changes");
|
||||
} finally {
|
||||
setSavingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
if (task) {
|
||||
setEditedTitle(task.title);
|
||||
setEditedDescription(task.description || "");
|
||||
setEditedStatus(task.status);
|
||||
}
|
||||
setIsEditMode(false);
|
||||
};
|
||||
|
||||
const createNewAttempt = async () => {
|
||||
if (!task || !projectId) return;
|
||||
|
||||
@@ -396,29 +374,16 @@ export function TaskDetailsPage() {
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Tasks
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{isEditMode ? "Edit Task" : "Task Details"}
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold">Task Details</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<Button onClick={saveTaskChanges} disabled={savingTask} size="sm">
|
||||
{savingTask ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button onClick={cancelEdit} variant="outline" size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setIsEditMode(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setIsTaskDialogOpen(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Edit Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -431,40 +396,22 @@ export function TaskDetailsPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Title</Label>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
value={editedTitle}
|
||||
onChange={(e) => setEditedTitle(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="Enter task title..."
|
||||
/>
|
||||
) : (
|
||||
<h2 className="text-lg font-semibold mt-1">{task.title}</h2>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold mt-1">{task.title}</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
{isEditMode ? (
|
||||
<Textarea
|
||||
value={editedDescription}
|
||||
onChange={(e) => setEditedDescription(e.target.value)}
|
||||
className="mt-1 min-h-[100px]"
|
||||
placeholder="Enter task description..."
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 p-3 bg-gray-50 rounded-md min-h-[60px]">
|
||||
{task.description ? (
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">
|
||||
{task.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
No description provided
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 p-3 bg-gray-50 rounded-md min-h-[60px]">
|
||||
{task.description ? (
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">
|
||||
{task.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
No description provided
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -528,41 +475,21 @@ export function TaskDetailsPage() {
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Status
|
||||
</Label>
|
||||
{isEditMode ? (
|
||||
<Select
|
||||
value={editedStatus}
|
||||
onValueChange={(value) =>
|
||||
setEditedStatus(value as TaskStatus)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="inprogress">In Progress</SelectItem>
|
||||
<SelectItem value="inreview">In Review</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div
|
||||
className={`mt-1 px-2 py-1 rounded-full text-xs font-medium w-fit ${
|
||||
task.status === "todo"
|
||||
? "bg-gray-100 text-gray-800"
|
||||
: task.status === "inprogress"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: task.status === "inreview"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: task.status === "done"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{statusLabels[task.status]}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mt-1 px-2 py-1 rounded-full text-xs font-medium w-fit ${
|
||||
task.status === "todo"
|
||||
? "bg-gray-100 text-gray-800"
|
||||
: task.status === "inprogress"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: task.status === "inreview"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: task.status === "done"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{statusLabels[task.status]}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -769,6 +696,14 @@ export function TaskDetailsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskFormDialog
|
||||
isOpen={isTaskDialogOpen}
|
||||
onOpenChange={setIsTaskDialogOpen}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
onUpdateTask={handleUpdateTaskFromDialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user