Files
stack/apps/web/src/app/(authenticated)/kanban/page.tsx
Jason Woltje 9305cacd4a
Some checks failed
ci/woodpecker/push/ci Pipeline failed
fix(web): kanban add-task tests (#645)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 23:03:21 +00:00

943 lines
26 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import type { ReactElement } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import type {
DropResult,
DroppableProvided,
DraggableProvided,
DraggableStateSnapshot,
} from "@hello-pangea/dnd";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
import { fetchTasks, updateTask, createTask, type TaskFilters } from "@/lib/api/tasks";
import { fetchProjects, type Project } from "@/lib/api/projects";
import { useWorkspaceId } from "@/lib/hooks";
import type { Task } from "@mosaic/shared";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
/* ---------------------------------------------------------------------------
Column configuration
--------------------------------------------------------------------------- */
interface ColumnConfig {
status: TaskStatus;
label: string;
accent: string;
}
const COLUMNS: ColumnConfig[] = [
{ status: TaskStatus.NOT_STARTED, label: "To Do", accent: "var(--ms-blue-400)" },
{ status: TaskStatus.IN_PROGRESS, label: "In Progress", accent: "var(--ms-amber-400)" },
{ status: TaskStatus.PAUSED, label: "Paused", accent: "var(--ms-purple-400)" },
{ status: TaskStatus.COMPLETED, label: "Done", accent: "var(--ms-teal-400)" },
{ status: TaskStatus.ARCHIVED, label: "Archived", accent: "var(--muted)" },
];
const PRIORITY_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "All Priorities" },
{ value: TaskPriority.HIGH, label: "High" },
{ value: TaskPriority.MEDIUM, label: "Medium" },
{ value: TaskPriority.LOW, label: "Low" },
];
/* ---------------------------------------------------------------------------
Filter select shared styles
--------------------------------------------------------------------------- */
const selectStyle: React.CSSProperties = {
padding: "6px 10px",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
background: "var(--surface)",
color: "var(--text)",
fontSize: "0.83rem",
outline: "none",
minWidth: 130,
};
const inputStyle: React.CSSProperties = {
...selectStyle,
minWidth: 180,
};
/* ---------------------------------------------------------------------------
Priority badge helper
--------------------------------------------------------------------------- */
interface PriorityStyle {
label: string;
bg: string;
color: string;
}
function getPriorityStyle(priority: TaskPriority): PriorityStyle {
switch (priority) {
case TaskPriority.HIGH:
return { label: "High", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" };
case TaskPriority.MEDIUM:
return { label: "Medium", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
case TaskPriority.LOW:
return { label: "Low", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
default:
return { label: String(priority), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
}
}
/* ---------------------------------------------------------------------------
Task Card
--------------------------------------------------------------------------- */
interface TaskCardProps {
task: Task;
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
columnAccent: string;
}
function TaskCard({ task, provided, snapshot, columnAccent }: TaskCardProps): ReactElement {
const [hovered, setHovered] = useState(false);
const priorityStyle = getPriorityStyle(task.priority);
return (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => {
setHovered(false);
}}
style={{
background: "var(--surface)",
border: `1px solid ${hovered || snapshot.isDragging ? columnAccent : "var(--border)"}`,
borderRadius: "var(--r)",
padding: 12,
marginBottom: 8,
cursor: "grab",
transition: "border-color 0.15s, box-shadow 0.15s",
boxShadow: snapshot.isDragging ? "var(--shadow-lg)" : "none",
...provided.draggableProps.style,
}}
>
{/* Title */}
<div
style={{
fontWeight: 600,
color: "var(--text)",
fontSize: "0.875rem",
marginBottom: 6,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{task.title}
</div>
{/* Priority badge */}
<span
style={{
display: "inline-block",
padding: "1px 8px",
borderRadius: "var(--r-sm)",
background: priorityStyle.bg,
color: priorityStyle.color,
fontSize: "0.7rem",
fontWeight: 500,
marginBottom: 6,
}}
>
{priorityStyle.label}
</span>
{/* Description */}
{task.description && (
<p
style={{
color: "var(--muted)",
fontSize: "0.8rem",
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
lineHeight: 1.4,
}}
>
{task.description}
</p>
)}
</div>
);
}
/* ---------------------------------------------------------------------------
Kanban Column
--------------------------------------------------------------------------- */
interface KanbanColumnProps {
config: ColumnConfig;
tasks: Task[];
onAddTask: (status: TaskStatus, title: string, projectId?: string) => Promise<void>;
projectId?: string;
}
function KanbanColumn({ config, tasks, onAddTask, projectId }: KanbanColumnProps): ReactElement {
const [showAddForm, setShowAddForm] = useState(false);
const [inputValue, setInputValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Focus input when form is shown
useEffect(() => {
if (showAddForm && inputRef.current) {
inputRef.current.focus();
}
}, [showAddForm]);
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
e.preventDefault();
if (!inputValue.trim() || isSubmitting) {
return;
}
setIsSubmitting(true);
try {
await onAddTask(config.status, inputValue.trim(), projectId);
setInputValue("");
setShowAddForm(false);
} catch (err) {
console.error("[KanbanColumn] Failed to add task:", err);
} finally {
setIsSubmitting(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Escape") {
setShowAddForm(false);
setInputValue("");
}
};
return (
<div
style={{
minWidth: 280,
maxWidth: 340,
flex: "1 0 280px",
display: "flex",
flexDirection: "column",
background: "var(--bg-mid)",
borderRadius: "var(--r-lg)",
overflow: "hidden",
}}
>
{/* Column header */}
<div
style={{
borderTop: `3px solid ${config.accent}`,
padding: "12px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span
style={{
fontWeight: 600,
fontSize: "0.85rem",
color: "var(--text)",
}}
>
{config.label}
</span>
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minWidth: 22,
height: 22,
padding: "0 6px",
borderRadius: "var(--r)",
background: `color-mix(in srgb, ${config.accent} 15%, transparent)`,
color: config.accent,
fontSize: "0.75rem",
fontWeight: 600,
fontFamily: "var(--mono)",
}}
>
{tasks.length}
</span>
</div>
{/* Droppable area */}
<Droppable droppableId={config.status}>
{(provided: DroppableProvided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
style={{
padding: "8px 12px 12px",
flex: 1,
minHeight: 80,
overflowY: "auto",
}}
>
{tasks.map((task, index) => (
<Draggable key={task.id} draggableId={task.id} index={index}>
{(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
<TaskCard
task={task}
provided={dragProvided}
snapshot={dragSnapshot}
columnAccent={config.accent}
/>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
{/* Add Task Form */}
{!showAddForm ? (
<button
type="button"
onClick={() => {
setShowAddForm(true);
}}
style={{
padding: "10px 16px",
border: "none",
background: "transparent",
color: "var(--muted)",
fontSize: "0.8rem",
cursor: "pointer",
textAlign: "left",
transition: "color 0.15s",
width: "100%",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--text)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--muted)";
}}
>
+ Add task
</button>
) : (
<form
onSubmit={handleSubmit}
style={{ padding: "8px 12px 12px", borderTop: "1px solid var(--border)" }}
>
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
}}
onKeyDown={handleKeyDown}
placeholder="Task title..."
disabled={isSubmitting}
style={{
width: "100%",
padding: "8px 10px",
borderRadius: "var(--r)",
border: `1px solid ${inputValue ? "var(--primary)" : "var(--border)"}`,
background: "var(--surface)",
color: "var(--text)",
fontSize: "0.85rem",
outline: "none",
opacity: isSubmitting ? 0.6 : 1,
}}
autoFocus
/>
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
<button
type="submit"
disabled={isSubmitting || !inputValue.trim()}
style={{
padding: "6px 12px",
borderRadius: "var(--r)",
border: "1px solid var(--primary)",
background: "var(--primary)",
color: "#fff",
fontSize: "0.8rem",
fontWeight: 500,
cursor: isSubmitting || !inputValue.trim() ? "not-allowed" : "pointer",
opacity: isSubmitting || !inputValue.trim() ? 0.5 : 1,
}}
>
Add
</button>
<button
type="button"
onClick={() => {
setShowAddForm(false);
setInputValue("");
}}
disabled={isSubmitting}
style={{
padding: "6px 12px",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
background: "transparent",
color: "var(--muted)",
fontSize: "0.8rem",
cursor: isSubmitting ? "not-allowed" : "pointer",
opacity: isSubmitting ? 0.5 : 1,
}}
>
Cancel
</button>
</div>
<div style={{ marginTop: 6, fontSize: "0.75rem", color: "var(--muted)" }}>
Press{" "}
<kbd
style={{
padding: "2px 4px",
background: "var(--bg-mid)",
borderRadius: "2px",
fontFamily: "var(--mono)",
}}
>
Enter
</kbd>{" "}
to save,{" "}
<kbd
style={{
padding: "2px 4px",
background: "var(--bg-mid)",
borderRadius: "2px",
fontFamily: "var(--mono)",
}}
>
Escape
</kbd>{" "}
to cancel
</div>
</form>
)}
</div>
);
}
/* ---------------------------------------------------------------------------
Filter Bar
--------------------------------------------------------------------------- */
interface FilterBarProps {
projects: Project[];
projectId: string;
priority: string;
search: string;
myTasks: boolean;
onProjectChange: (value: string) => void;
onPriorityChange: (value: string) => void;
onSearchChange: (value: string) => void;
onMyTasksToggle: () => void;
onClear: () => void;
hasActiveFilters: boolean;
}
function FilterBar({
projects,
projectId,
priority,
search,
myTasks,
onProjectChange,
onPriorityChange,
onSearchChange,
onMyTasksToggle,
onClear,
hasActiveFilters,
}: FilterBarProps): ReactElement {
return (
<div
style={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 8,
padding: "10px 14px",
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
marginBottom: 16,
}}
>
{/* Search */}
<input
type="text"
placeholder="Search tasks..."
value={search}
onChange={(e): void => {
onSearchChange(e.target.value);
}}
style={inputStyle}
/>
{/* Project filter */}
<select
value={projectId}
onChange={(e): void => {
onProjectChange(e.target.value);
}}
style={selectStyle}
>
<option value="">All Projects</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
{/* Priority filter */}
<select
value={priority}
onChange={(e): void => {
onPriorityChange(e.target.value);
}}
style={selectStyle}
>
{PRIORITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{/* My Tasks toggle */}
<button
type="button"
onClick={onMyTasksToggle}
style={{
padding: "6px 12px",
borderRadius: "var(--r)",
border: myTasks ? "1px solid var(--primary)" : "1px solid var(--border)",
background: myTasks ? "var(--primary)" : "transparent",
color: myTasks ? "#fff" : "var(--text-2)",
fontSize: "0.83rem",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.12s ease",
whiteSpace: "nowrap",
}}
>
My Tasks
</button>
{/* Clear filters */}
{hasActiveFilters && (
<button
type="button"
onClick={onClear}
style={{
padding: "6px 12px",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
background: "transparent",
color: "var(--muted)",
fontSize: "0.83rem",
fontWeight: 500,
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
Clear
</button>
)}
</div>
);
}
/* ---------------------------------------------------------------------------
Kanban Board Page
--------------------------------------------------------------------------- */
export default function KanbanPage(): ReactElement {
const workspaceId = useWorkspaceId();
const router = useRouter();
const searchParams = useSearchParams();
const [tasks, setTasks] = useState<Task[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Read filters from URL params
const filterProject = searchParams.get("project") ?? "";
const filterPriority = searchParams.get("priority") ?? "";
const filterSearch = searchParams.get("q") ?? "";
const filterMyTasks = searchParams.get("my") === "1";
const hasActiveFilters =
filterProject !== "" || filterPriority !== "" || filterSearch !== "" || filterMyTasks;
/** Update a single URL param (preserving others) */
const setParam = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
router.replace(`/kanban?${params.toString()}`, { scroll: false });
},
[searchParams, router]
);
const handleProjectChange = useCallback(
(value: string) => {
setParam("project", value);
},
[setParam]
);
const handlePriorityChange = useCallback(
(value: string) => {
setParam("priority", value);
},
[setParam]
);
const handleSearchChange = useCallback(
(value: string) => {
setParam("q", value);
},
[setParam]
);
const handleMyTasksToggle = useCallback(() => {
setParam("my", filterMyTasks ? "" : "1");
}, [setParam, filterMyTasks]);
const handleClearFilters = useCallback(() => {
router.replace("/kanban", { scroll: false });
}, [router]);
/* --- data fetching --- */
const loadTasks = useCallback(async (wsId: string | null): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const filters = wsId !== null ? { workspaceId: wsId } : {};
const data = await fetchTasks(filters);
setTasks(data);
} catch (err: unknown) {
console.error("[Kanban] Failed to fetch tasks:", err);
setError(
err instanceof Error
? err.message
: "Something went wrong loading tasks. You could try again when ready."
);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (!workspaceId) {
setIsLoading(false);
return;
}
const ac = new AbortController();
async function load(): Promise<void> {
try {
setIsLoading(true);
setError(null);
const filters: TaskFilters = {};
if (workspaceId) filters.workspaceId = workspaceId;
const [taskData, projectData] = await Promise.all([
fetchTasks(filters),
fetchProjects(workspaceId ?? undefined),
]);
if (ac.signal.aborted) return;
setTasks(taskData);
setProjects(projectData);
} catch (err: unknown) {
console.error("[Kanban] Failed to fetch tasks:", err);
if (ac.signal.aborted) return;
setError(
err instanceof Error
? err.message
: "Something went wrong loading tasks. You could try again when ready."
);
} finally {
if (!ac.signal.aborted) {
setIsLoading(false);
}
}
}
void load();
return (): void => {
ac.abort();
};
}, [workspaceId]);
/* --- apply client-side filters --- */
const filteredTasks = useMemo(() => {
let result = tasks;
if (filterProject) {
result = result.filter((t) => t.projectId === filterProject);
}
if (filterPriority) {
result = result.filter((t) => t.priority === (filterPriority as TaskPriority));
}
if (filterSearch) {
const q = filterSearch.toLowerCase();
result = result.filter(
(t) => t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)
);
}
if (filterMyTasks) {
// "My Tasks" filters to tasks assigned to the current user.
// Since we don't have the current userId readily available,
// filter by assigneeId being non-null (assigned tasks).
// A proper implementation would compare against the logged-in user's ID.
result = result.filter((t) => t.assigneeId !== null);
}
return result;
}, [tasks, filterProject, filterPriority, filterSearch, filterMyTasks]);
/* --- group tasks by status --- */
function groupByStatus(allTasks: Task[]): Record<TaskStatus, Task[]> {
const grouped: Record<TaskStatus, Task[]> = {
[TaskStatus.NOT_STARTED]: [],
[TaskStatus.IN_PROGRESS]: [],
[TaskStatus.PAUSED]: [],
[TaskStatus.COMPLETED]: [],
[TaskStatus.ARCHIVED]: [],
};
for (const task of allTasks) {
grouped[task.status].push(task);
}
return grouped;
}
const grouped = groupByStatus(filteredTasks);
/* --- drag-and-drop handler --- */
const handleDragEnd = useCallback(
(result: DropResult) => {
const { source, destination, draggableId } = result;
// Dropped outside a droppable area
if (!destination) return;
// Dropped in same position
if (source.droppableId === destination.droppableId && source.index === destination.index) {
return;
}
const newStatus = destination.droppableId as TaskStatus;
const taskId = draggableId;
// Optimistic update: move card in local state
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
// Persist to API
const wsId = workspaceId ?? undefined;
updateTask(taskId, { status: newStatus }, wsId).catch((err: unknown) => {
console.error("[Kanban] Failed to update task status:", err);
// Revert on failure by re-fetching
void loadTasks(workspaceId);
});
},
[workspaceId, loadTasks]
);
/* --- retry handler --- */
function handleRetry(): void {
void loadTasks(workspaceId);
}
/* --- add task handler --- */
const handleAddTask = useCallback(
async (status: TaskStatus, title: string, projectId?: string) => {
try {
const wsId = workspaceId ?? undefined;
const taskData: { title: string; status: TaskStatus; projectId?: string } = {
title,
status,
};
if (projectId) {
taskData.projectId = projectId;
}
const newTask = await createTask(taskData, wsId);
// Optimistically add to local state
setTasks((prev) => [...prev, newTask]);
} catch (err: unknown) {
console.error("[Kanban] Failed to create task:", err);
// Re-fetch on error to get consistent state
void loadTasks(workspaceId);
}
},
[workspaceId, loadTasks]
);
/* --- render --- */
return (
<main style={{ padding: "32px 24px", minHeight: "100%" }}>
{/* Page header */}
<div style={{ marginBottom: 16 }}>
<h1
style={{
fontSize: "1.875rem",
fontWeight: 700,
color: "var(--text)",
margin: 0,
}}
>
Kanban Board
</h1>
<p
style={{
fontSize: "0.9rem",
color: "var(--muted)",
marginTop: 4,
}}
>
Visualize and manage task progress across stages
</p>
</div>
{/* Filter bar */}
<FilterBar
projects={projects}
projectId={filterProject}
priority={filterPriority}
search={filterSearch}
myTasks={filterMyTasks}
onProjectChange={handleProjectChange}
onPriorityChange={handlePriorityChange}
onSearchChange={handleSearchChange}
onMyTasksToggle={handleMyTasksToggle}
onClear={handleClearFilters}
hasActiveFilters={hasActiveFilters}
/>
{/* Loading state */}
{isLoading ? (
<div className="flex justify-center py-16">
<MosaicSpinner label="Loading tasks..." />
</div>
) : error !== null ? (
/* Error state */
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
padding: 32,
textAlign: "center",
}}
>
<p style={{ color: "var(--danger)", margin: "0 0 16px" }}>{error}</p>
<button
onClick={handleRetry}
style={{
padding: "8px 16px",
background: "var(--danger)",
border: "none",
borderRadius: "var(--r)",
color: "#fff",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Try again
</button>
</div>
) : filteredTasks.length === 0 && tasks.length > 0 ? (
/* No results (filtered) */
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
padding: 48,
textAlign: "center",
}}
>
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
No tasks match your filters.
</p>
<button
type="button"
onClick={handleClearFilters}
style={{
marginTop: 12,
padding: "6px 14px",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
background: "transparent",
color: "var(--text-2)",
fontSize: "0.83rem",
cursor: "pointer",
}}
>
Clear filters
</button>
</div>
) : (
/* Board (always render columns to allow adding first task) */
<DragDropContext onDragEnd={handleDragEnd}>
<div
style={{
display: "flex",
gap: 16,
overflowX: "auto",
paddingBottom: 16,
minHeight: 400,
}}
>
{COLUMNS.map((col) => (
<KanbanColumn
key={col.status}
config={col}
tasks={grouped[col.status]}
onAddTask={handleAddTask}
projectId={filterProject}
/>
))}
</div>
</DragDropContext>
)}
</main>
);
}