feat(kanban): inline task creation in columns

This commit is contained in:
2026-07-29 19:21:41 +00:00
parent 3708505b00
commit 28d01f3839
@@ -13,10 +13,17 @@ import {
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Calendar, GripVertical, Plus } from 'lucide-react'; import { Input } from '@/components/ui/input';
import { Calendar, GripVertical, Link2, Loader2, Plus } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { TaskDetailPanel } from './task-detail-panel'; import { TaskDetailPanel } from './task-detail-panel';
import { useRealtimeContext } from '@/components/realtime-provider'; import { useRealtimeContext } from '@/components/realtime-provider';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface Task { interface Task {
id: string; id: string;
@@ -59,15 +66,15 @@ function TaskCard({
task, task,
domainName, domainName,
domainColor, domainColor,
depCount,
onClick, onClick,
}: { }: {
task: Task; task: Task;
domainName: string; domainName: string;
domainColor: string | null; domainColor: string | null;
depCount: number;
onClick: () => void; onClick: () => void;
}) { }) {
const subtaskCount = 0; // Will be populated from API
return ( return (
<Card className="mb-2 cursor-pointer hover:shadow-md transition-shadow" onClick={onClick}> <Card className="mb-2 cursor-pointer hover:shadow-md transition-shadow" onClick={onClick}>
<CardContent className="p-3"> <CardContent className="p-3">
@@ -75,6 +82,20 @@ function TaskCard({
<span className="flex-1 text-sm font-medium leading-tight"> <span className="flex-1 text-sm font-medium leading-tight">
{task.title} {task.title}
</span> </span>
{depCount > 0 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center text-xs text-muted-foreground shrink-0">
<Link2 className="h-3 w-3" />
</span>
</TooltipTrigger>
<TooltipContent side="top">
<p>{depCount} {depCount === 1 ? 'dependency' : 'dependencies'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Badge <Badge
@@ -130,6 +151,10 @@ function DroppableColumn({
domainMap, domainMap,
onTaskClick, onTaskClick,
onAddTask, onAddTask,
inlineInput,
onInlineInputChange,
onInlineSubmit,
inlineLoading,
}: { }: {
id: string; id: string;
title: string; title: string;
@@ -138,6 +163,10 @@ function DroppableColumn({
domainMap: Map<string, { name: string; color: string | null }>; domainMap: Map<string, { name: string; color: string | null }>;
onTaskClick: (task: Task) => void; onTaskClick: (task: Task) => void;
onAddTask: () => void; onAddTask: () => void;
inlineInput: string;
onInlineInputChange: (value: string) => void;
onInlineSubmit: () => void;
inlineLoading: boolean;
}) { }) {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
@@ -173,11 +202,35 @@ function DroppableColumn({
task={task} task={task}
domainName={domainMap.get(task.domainId)?.name || ''} domainName={domainMap.get(task.domainId)?.name || ''}
domainColor={domainMap.get(task.domainId)?.color || null} domainColor={domainMap.get(task.domainId)?.color || null}
depCount={0}
onClick={() => onTaskClick(task)} onClick={() => onTaskClick(task)}
/> />
)) ))
)} )}
</div> </div>
{/* Inline task creation input */}
<div className="mt-2 flex items-center gap-2">
<Input
placeholder="New task title..."
value={inlineInput}
onChange={(e) => onInlineInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !inlineLoading) {
onInlineSubmit();
}
}}
className="h-8 text-sm"
disabled={inlineLoading}
/>
<Button
size="sm"
className="h-8 shrink-0"
onClick={onInlineSubmit}
disabled={!inlineInput.trim() || inlineLoading}
>
{inlineLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Add'}
</Button>
</div>
</div> </div>
); );
} }
@@ -193,6 +246,8 @@ export function TasksKanbanView({
const [domainMap, setDomainMap] = useState<Map<string, { name: string; color: string | null }>>(new Map()); const [domainMap, setDomainMap] = useState<Map<string, { name: string; color: string | null }>>(new Map());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedTask, setSelectedTask] = useState<Task | null>(null); const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [inlineInputs, setInlineInputs] = useState<Record<string, string>>({});
const [inlineLoading, setInlineLoading] = useState<Record<string, boolean>>({});
const { subscribe } = useRealtimeContext(); const { subscribe } = useRealtimeContext();
const sensors = useSensors( const sensors = useSensors(
@@ -279,11 +334,30 @@ export function TasksKanbanView({
} }
} }
function handleAddTask(status: string) { async function handleInlineCreate(status: string) {
// Open create dialog with pre-filled status const title = inlineInputs[status]?.trim();
document.dispatchEvent( if (!title) return;
new CustomEvent('open-create-task', { detail: { status, domainId } })
); setInlineLoading((prev) => ({ ...prev, [status]: true }));
try {
const response = await fetch(`/api/domains/${domainId}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, status }),
});
if (!response.ok) throw new Error('Unable to create task');
const newTask = await response.json();
// Prepend the new task to the list
setTasks((prev) => [{ ...newTask, tags: [] }, ...prev]);
setInlineInputs((prev) => ({ ...prev, [status]: '' }));
toast.success(`Created "${title}"`);
} catch (error) {
console.error('Failed to create task:', error);
toast.error('Unable to create task');
} finally {
setInlineLoading((prev) => ({ ...prev, [status]: false }));
}
} }
if (loading) { if (loading) {
@@ -302,7 +376,13 @@ export function TasksKanbanView({
tasks={tasks.filter((t) => t.status === column.id)} tasks={tasks.filter((t) => t.status === column.id)}
domainMap={domainMap} domainMap={domainMap}
onTaskClick={setSelectedTask} onTaskClick={setSelectedTask}
onAddTask={() => handleAddTask(column.id)} onAddTask={() => {}}
inlineInput={inlineInputs[column.id] || ''}
onInlineInputChange={(value) =>
setInlineInputs((prev) => ({ ...prev, [column.id]: value }))
}
onInlineSubmit={() => handleInlineCreate(column.id)}
inlineLoading={inlineLoading[column.id] || false}
/> />
))} ))}
</div> </div>