Add duplicate task button (vibe-kanban) (#593)
* ## ✅ Implementation Complete!
I've successfully implemented the duplicate task feature. Here's what was accomplished:
### **Changes Made:**
1. **TaskDialogContext (`frontend/src/contexts/task-dialog-context.tsx`)**
- Added `initialTask: Task | null` to dialog state
- Created `openDuplicate(task, options)` method that opens create dialog with initial data
- Updated all existing methods to include initialTask field
2. **TaskFormDialog (`frontend/src/components/tasks/TaskFormDialog.tsx`)**
- Added `initialTask?: Task | null` prop
- Enhanced prefill logic with priority: Edit → Duplicate → Template → Empty
- Duplicated tasks reset status to 'todo' and start with no images
3. **TaskCard (`frontend/src/components/tasks/TaskCard.tsx`)**
- Added Copy icon import
- Added `onDuplicate?: (task: Task) => void` prop
- Added "Duplicate" menu item with Copy icon between Edit and Delete
4. **TaskKanbanBoard (`frontend/src/components/tasks/TaskKanbanBoard.tsx`)**
- Added `onDuplicateTask?: (task: Task) => void` prop
- Passed duplicate handler through to TaskCard components
5. **ProjectTasks (`frontend/src/pages/project-tasks.tsx`)**
- Added `openDuplicate` to useTaskDialog destructuring
- Created `handleDuplicateTask` function
- Passed duplicate handler to TaskKanbanBoard
6. **TaskFormDialogContainer (`frontend/src/components/tasks/TaskFormDialogContainer.tsx`)**
- Added `initialTask={dialogState.initialTask}` prop to TaskFormDialog
### **How It Works:**
1. **User clicks** the "⋯" menu on any task card
2. **Sees "Duplicate"** option with copy icon between Edit and Delete
3. **Clicks Duplicate** → opens create task dialog with:
- Title and description pre-filled from original task
- Status reset to 'todo'
- No images (clean slate)
- User can modify and create as normal
### **Key Design Decisions:**
- **Treated as create mode** (not edit) - follows Oracle's recommendation
- **Status always resets to 'todo'** for duplicated tasks
- **No images copied** - simple and safe approach
- **Clean integration** with existing template system
The implementation passes all TypeScript checks and follows the existing codebase patterns. The feature is ready to use!
* Cleanup script changes for task attempt 16b21999-278c-414d-aaab-ae5767d42d77
This commit is contained in:
committed by
GitHub
parent
305ad90a70
commit
5505a387bc
@@ -1525,9 +1525,9 @@ mod tests {
|
||||
match &entries[0].entry_type {
|
||||
NormalizedEntryType::ToolUse { action_type, .. } => match action_type {
|
||||
ActionType::FileEdit { path, .. } => assert_eq!(path, "src/new.txt"),
|
||||
other => panic!("Expected FileEdit, got {:?}", other),
|
||||
other => panic!("Expected FileEdit, got {other:?}"),
|
||||
},
|
||||
other => panic!("Expected ToolUse, got {:?}", other),
|
||||
other => panic!("Expected ToolUse, got {other:?}"),
|
||||
}
|
||||
|
||||
// Amp "edit_file" should deserialize into Edit with aliases for path/old_str/new_str
|
||||
@@ -1546,9 +1546,9 @@ mod tests {
|
||||
match &entries[0].entry_type {
|
||||
NormalizedEntryType::ToolUse { action_type, .. } => match action_type {
|
||||
ActionType::FileEdit { path, .. } => assert_eq!(path, "README.md"),
|
||||
other => panic!("Expected FileEdit, got {:?}", other),
|
||||
other => panic!("Expected FileEdit, got {other:?}"),
|
||||
},
|
||||
other => panic!("Expected ToolUse, got {:?}", other),
|
||||
other => panic!("Expected ToolUse, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { KanbanCard } from '@/components/ui/shadcn-io/kanban';
|
||||
import {
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Edit,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
@@ -25,6 +26,7 @@ interface TaskCardProps {
|
||||
status: string;
|
||||
onEdit: (task: Task) => void;
|
||||
onDelete: (taskId: string) => void;
|
||||
onDuplicate?: (task: Task) => void;
|
||||
onViewDetails: (task: Task) => void;
|
||||
isFocused: boolean;
|
||||
tabIndex?: number;
|
||||
@@ -36,6 +38,7 @@ export function TaskCard({
|
||||
status,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onViewDetails,
|
||||
isFocused,
|
||||
tabIndex = -1,
|
||||
@@ -114,6 +117,12 @@ export function TaskCard({
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{onDuplicate && (
|
||||
<DropdownMenuItem onClick={() => onDuplicate(task)}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(task.id)}
|
||||
className="text-destructive"
|
||||
|
||||
@@ -37,6 +37,7 @@ interface TaskFormDialogProps {
|
||||
task?: Task | null; // Optional for create mode
|
||||
projectId?: string; // For file search functionality
|
||||
initialTemplate?: TaskTemplate | null; // For pre-filling from template
|
||||
initialTask?: Task | null; // For duplicating an existing task
|
||||
onCreateTask?: (
|
||||
title: string,
|
||||
description: string,
|
||||
@@ -61,6 +62,7 @@ export function TaskFormDialog({
|
||||
task,
|
||||
projectId,
|
||||
initialTemplate,
|
||||
initialTask,
|
||||
onCreateTask,
|
||||
onCreateAndStartTask,
|
||||
onUpdateTask,
|
||||
@@ -132,6 +134,14 @@ export function TaskFormDialog({
|
||||
setImages([]);
|
||||
});
|
||||
}
|
||||
} else if (initialTask) {
|
||||
// Duplicate mode - pre-fill from existing task but reset status to 'todo' and no images
|
||||
setTitle(initialTask.title);
|
||||
setDescription(initialTask.description || '');
|
||||
setStatus('todo'); // Always start duplicated tasks as 'todo'
|
||||
setSelectedTemplate('');
|
||||
setImages([]);
|
||||
setNewlyUploadedImageIds([]);
|
||||
} else if (initialTemplate) {
|
||||
// Create mode with template - pre-fill from template
|
||||
setTitle(initialTemplate.title);
|
||||
@@ -147,7 +157,7 @@ export function TaskFormDialog({
|
||||
setImages([]);
|
||||
setNewlyUploadedImageIds([]);
|
||||
}
|
||||
}, [task, initialTemplate, isOpen]);
|
||||
}, [task, initialTask, initialTemplate, isOpen]);
|
||||
|
||||
// Fetch templates when dialog opens in create mode
|
||||
useEffect(() => {
|
||||
|
||||
@@ -129,6 +129,7 @@ export function TaskFormDialogContainer() {
|
||||
task={dialogState.task}
|
||||
projectId={projectId || undefined}
|
||||
initialTemplate={dialogState.initialTemplate}
|
||||
initialTask={dialogState.initialTask}
|
||||
onCreateTask={handleCreateTask}
|
||||
onCreateAndStartTask={handleCreateAndStartTask}
|
||||
onUpdateTask={handleUpdateTask}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface TaskKanbanBoardProps {
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onEditTask: (task: Task) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
onDuplicateTask?: (task: Task) => void;
|
||||
onViewTaskDetails: (task: Task) => void;
|
||||
isPanelOpen: boolean;
|
||||
}
|
||||
@@ -41,6 +42,7 @@ function TaskKanbanBoard({
|
||||
onDragEnd,
|
||||
onEditTask,
|
||||
onDeleteTask,
|
||||
onDuplicateTask,
|
||||
onViewTaskDetails,
|
||||
isPanelOpen,
|
||||
}: TaskKanbanBoardProps) {
|
||||
@@ -150,6 +152,7 @@ function TaskKanbanBoard({
|
||||
status={status}
|
||||
onEdit={onEditTask}
|
||||
onDelete={onDeleteTask}
|
||||
onDuplicate={onDuplicateTask}
|
||||
onViewDetails={onViewTaskDetails}
|
||||
isFocused={focusedTaskId === task.id}
|
||||
tabIndex={focusedTaskId === task.id ? 0 : -1}
|
||||
|
||||
@@ -27,6 +27,7 @@ interface TaskDialogState {
|
||||
mode: 'create' | 'edit';
|
||||
task: Task | null;
|
||||
initialTemplate: TaskTemplate | null;
|
||||
initialTask: Task | null;
|
||||
afterSubmit?: (task: Task) => void;
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ interface TaskDialogAPI {
|
||||
template: TaskTemplate,
|
||||
options?: TaskDialogOptions
|
||||
) => void;
|
||||
openDuplicate: (task: Task, options?: TaskDialogOptions) => void;
|
||||
close: () => void;
|
||||
|
||||
// For dialog component to call after successful operations
|
||||
@@ -59,6 +61,7 @@ export function TaskDialogProvider({ children }: TaskDialogProviderProps) {
|
||||
mode: 'create',
|
||||
task: null,
|
||||
initialTemplate: null,
|
||||
initialTask: null,
|
||||
afterSubmit: undefined,
|
||||
});
|
||||
|
||||
@@ -68,6 +71,7 @@ export function TaskDialogProvider({ children }: TaskDialogProviderProps) {
|
||||
mode: 'create',
|
||||
task: null,
|
||||
initialTemplate: null,
|
||||
initialTask: null,
|
||||
afterSubmit: options?.onSuccess,
|
||||
});
|
||||
}, []);
|
||||
@@ -78,6 +82,7 @@ export function TaskDialogProvider({ children }: TaskDialogProviderProps) {
|
||||
mode: 'edit',
|
||||
task,
|
||||
initialTemplate: null,
|
||||
initialTask: null,
|
||||
afterSubmit: options?.onSuccess,
|
||||
});
|
||||
}, []);
|
||||
@@ -89,6 +94,21 @@ export function TaskDialogProvider({ children }: TaskDialogProviderProps) {
|
||||
mode: 'create',
|
||||
task: null,
|
||||
initialTemplate: template,
|
||||
initialTask: null,
|
||||
afterSubmit: options?.onSuccess,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const openDuplicate = useCallback(
|
||||
(sourceTask: Task, options?: TaskDialogOptions) => {
|
||||
setDialogState({
|
||||
isOpen: true,
|
||||
mode: 'create',
|
||||
task: null,
|
||||
initialTemplate: null,
|
||||
initialTask: sourceTask,
|
||||
afterSubmit: options?.onSuccess,
|
||||
});
|
||||
},
|
||||
@@ -119,10 +139,19 @@ export function TaskDialogProvider({ children }: TaskDialogProviderProps) {
|
||||
openCreate,
|
||||
openEdit,
|
||||
openFromTemplate,
|
||||
openDuplicate,
|
||||
close,
|
||||
handleSuccess,
|
||||
}),
|
||||
[dialogState, openCreate, openEdit, openFromTemplate, close, handleSuccess]
|
||||
[
|
||||
dialogState,
|
||||
openCreate,
|
||||
openEdit,
|
||||
openFromTemplate,
|
||||
openDuplicate,
|
||||
close,
|
||||
handleSuccess,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -45,7 +45,7 @@ export function ProjectTasks() {
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { openCreate, openEdit } = useTaskDialog();
|
||||
const { openCreate, openEdit, openDuplicate } = useTaskDialog();
|
||||
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
|
||||
const { query: searchQuery } = useSearch();
|
||||
|
||||
@@ -187,6 +187,13 @@ export function ProjectTasks() {
|
||||
[openEdit]
|
||||
);
|
||||
|
||||
const handleDuplicateTask = useCallback(
|
||||
(task: Task) => {
|
||||
openDuplicate(task);
|
||||
},
|
||||
[openDuplicate]
|
||||
);
|
||||
|
||||
const handleViewTaskDetails = useCallback(
|
||||
(task: Task, attemptIdToShow?: string) => {
|
||||
// setSelectedTask(task);
|
||||
@@ -336,6 +343,7 @@ export function ProjectTasks() {
|
||||
onDragEnd={handleDragEnd}
|
||||
onEditTask={handleEditTask}
|
||||
onDeleteTask={handleDeleteTask}
|
||||
onDuplicateTask={handleDuplicateTask}
|
||||
onViewTaskDetails={handleViewTaskDetails}
|
||||
isPanelOpen={isPanelOpen}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user