feat(kanban): inline task creation in columns
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user