feat(frontend): Add Sprint 4 new fields to Story Detail page sidebar

Add three new cards to Story Detail sidebar to display Sprint 4 Story 3 fields:
- Story Points card with Target icon
- Tags card with Tag badges
- Acceptance Criteria card with CheckCircle2 icons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-05 23:18:39 +01:00
parent 79f210d0ee
commit f2aa3b03b6
4 changed files with 270 additions and 287 deletions

View File

@@ -1,9 +1,9 @@
'use client';
"use client";
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
@@ -12,51 +12,45 @@ import {
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCreateStory, useUpdateStory } from '@/lib/hooks/use-stories';
import { useEpics } from '@/lib/hooks/use-epics';
import type { Story, WorkItemPriority } from '@/types/project';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores/authStore';
import { AcceptanceCriteriaEditor } from './acceptance-criteria-editor';
import { TagsInput } from './tags-input';
} from "@/components/ui/select";
import { useCreateStory, useUpdateStory } from "@/lib/hooks/use-stories";
import { useEpics } from "@/lib/hooks/use-epics";
import type { Story } from "@/types/project";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { useAuthStore } from "@/stores/authStore";
import { AcceptanceCriteriaEditor } from "./acceptance-criteria-editor";
import { TagsInput } from "./tags-input";
const storySchema = z.object({
epicId: z.string().min(1, 'Parent Epic is required'),
title: z
.string()
.min(1, 'Title is required')
.max(200, 'Title must be less than 200 characters'),
description: z
.string()
.max(2000, 'Description must be less than 2000 characters')
.optional(),
priority: z.enum(['Low', 'Medium', 'High', 'Critical']),
epicId: z.string().min(1, "Parent Epic is required"),
title: z.string().min(1, "Title is required").max(200, "Title must be less than 200 characters"),
description: z.string().max(2000, "Description must be less than 2000 characters").optional(),
priority: z.enum(["Low", "Medium", "High", "Critical"]),
estimatedHours: z
.number()
.min(0, 'Estimated hours must be positive')
.min(0, "Estimated hours must be positive")
.optional()
.or(z.literal('')),
.or(z.literal("")),
// Sprint 4 Story 3: New fields
acceptanceCriteria: z.array(z.string()).default([]),
acceptanceCriteria: z.array(z.string()).optional(),
assigneeId: z.string().optional(),
tags: z.array(z.string()).default([]),
tags: z.array(z.string()).optional(),
storyPoints: z
.number()
.min(0, 'Story points must be positive')
.max(100, 'Story points must be less than 100')
.min(0, "Story points must be positive")
.max(100, "Story points must be less than 100")
.optional()
.or(z.literal('')),
.or(z.literal("")),
});
type StoryFormValues = z.infer<typeof storySchema>;
@@ -69,13 +63,7 @@ interface StoryFormProps {
onCancel?: () => void;
}
export function StoryForm({
story,
epicId,
projectId,
onSuccess,
onCancel,
}: StoryFormProps) {
export function StoryForm({ story, epicId, projectId, onSuccess, onCancel }: StoryFormProps) {
const isEditing = !!story;
const user = useAuthStore((state) => state.user);
const createStory = useCreateStory();
@@ -87,16 +75,16 @@ export function StoryForm({
const form = useForm<StoryFormValues>({
resolver: zodResolver(storySchema),
defaultValues: {
epicId: story?.epicId || epicId || '',
title: story?.title || '',
description: story?.description || '',
priority: story?.priority || 'Medium',
estimatedHours: story?.estimatedHours || ('' as any),
epicId: story?.epicId || epicId || "",
title: story?.title || "",
description: story?.description || "",
priority: story?.priority || "Medium",
estimatedHours: story?.estimatedHours || ("" as const),
// Sprint 4 Story 3: New field defaults
acceptanceCriteria: story?.acceptanceCriteria || [],
assigneeId: story?.assigneeId || '',
assigneeId: story?.assigneeId || "",
tags: story?.tags || [],
storyPoints: story?.storyPoints || ('' as any),
storyPoints: story?.storyPoints || ("" as const),
},
});
@@ -110,23 +98,22 @@ export function StoryForm({
description: data.description,
priority: data.priority,
estimatedHours:
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
typeof data.estimatedHours === "number" ? data.estimatedHours : undefined,
// Sprint 4 Story 3: New fields
acceptanceCriteria: data.acceptanceCriteria,
assigneeId: data.assigneeId || undefined,
tags: data.tags,
storyPoints:
typeof data.storyPoints === 'number' ? data.storyPoints : undefined,
storyPoints: typeof data.storyPoints === "number" ? data.storyPoints : undefined,
},
});
toast.success('Story updated successfully');
toast.success("Story updated successfully");
} else {
if (!user?.id) {
toast.error('User not authenticated');
toast.error("User not authenticated");
return;
}
if (!projectId) {
toast.error('Project ID is required');
toast.error("Project ID is required");
return;
}
await createStory.mutateAsync({
@@ -135,21 +122,19 @@ export function StoryForm({
title: data.title,
description: data.description,
priority: data.priority,
estimatedHours:
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
estimatedHours: typeof data.estimatedHours === "number" ? data.estimatedHours : undefined,
createdBy: user.id,
// Sprint 4 Story 3: New fields
acceptanceCriteria: data.acceptanceCriteria,
assigneeId: data.assigneeId || undefined,
tags: data.tags,
storyPoints:
typeof data.storyPoints === 'number' ? data.storyPoints : undefined,
storyPoints: typeof data.storyPoints === "number" ? data.storyPoints : undefined,
});
toast.success('Story created successfully');
toast.success("Story created successfully");
}
onSuccess?.();
} catch (error) {
const message = error instanceof Error ? error.message : 'Operation failed';
const message = error instanceof Error ? error.message : "Operation failed";
toast.error(message);
}
}
@@ -177,11 +162,9 @@ export function StoryForm({
</FormControl>
<SelectContent>
{epicsLoading ? (
<div className="p-2 text-sm text-muted-foreground">Loading epics...</div>
<div className="text-muted-foreground p-2 text-sm">Loading epics...</div>
) : epics.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground">
No epics available
</div>
<div className="text-muted-foreground p-2 text-sm">No epics available</div>
) : (
epics.map((epic) => (
<SelectItem key={epic.id} value={epic.id}>
@@ -192,7 +175,7 @@ export function StoryForm({
</SelectContent>
</Select>
<FormDescription>
{isEditing ? 'Parent epic cannot be changed' : 'Select the parent epic'}
{isEditing ? "Parent epic cannot be changed" : "Select the parent epic"}
</FormDescription>
<FormMessage />
</FormItem>
@@ -228,9 +211,7 @@ export function StoryForm({
{...field}
/>
</FormControl>
<FormDescription>
Optional detailed description (max 2000 characters)
</FormDescription>
<FormDescription>Optional detailed description (max 2000 characters)</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -276,9 +257,9 @@ export function StoryForm({
{...field}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? '' : parseFloat(value));
field.onChange(value === "" ? "" : parseFloat(value));
}}
value={field.value === undefined ? '' : field.value}
value={field.value === undefined ? "" : field.value}
/>
</FormControl>
<FormDescription>Optional time estimate</FormDescription>
@@ -297,7 +278,7 @@ export function StoryForm({
<FormLabel>Acceptance Criteria</FormLabel>
<FormControl>
<AcceptanceCriteriaEditor
criteria={field.value}
criteria={field.value || []}
onChange={field.onChange}
disabled={isLoading}
/>
@@ -318,11 +299,7 @@ export function StoryForm({
render={({ field }) => (
<FormItem>
<FormLabel>Assignee</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={isLoading}
>
<Select onValueChange={field.onChange} value={field.value} disabled={isLoading}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Unassigned" />
@@ -330,9 +307,7 @@ export function StoryForm({
</FormControl>
<SelectContent>
<SelectItem value="">Unassigned</SelectItem>
{user?.id && (
<SelectItem value={user.id}>{user.fullName || 'Me'}</SelectItem>
)}
{user?.id && <SelectItem value={user.id}>{user.fullName || "Me"}</SelectItem>}
</SelectContent>
</Select>
<FormDescription>Assign to team member</FormDescription>
@@ -357,9 +332,9 @@ export function StoryForm({
{...field}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? '' : parseInt(value));
field.onChange(value === "" ? "" : parseInt(value));
}}
value={field.value === undefined ? '' : field.value}
value={field.value === undefined ? "" : field.value}
disabled={isLoading}
/>
</FormControl>
@@ -379,7 +354,7 @@ export function StoryForm({
<FormLabel>Tags</FormLabel>
<FormControl>
<TagsInput
tags={field.value}
tags={field.value || []}
onChange={field.onChange}
disabled={isLoading}
placeholder="Add tags (press Enter)..."
@@ -395,18 +370,13 @@ export function StoryForm({
<div className="flex justify-end gap-3">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading}
>
<Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}>
Cancel
</Button>
)}
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? 'Update Story' : 'Create Story'}
{isEditing ? "Update Story" : "Create Story"}
</Button>
</div>
</form>

View File

@@ -1,29 +1,21 @@
'use client';
"use client";
import { useState } from 'react';
import { Task, WorkItemStatus } from '@/types/project';
import { useChangeTaskStatus, useUpdateTask, useDeleteTask } from '@/lib/hooks/use-tasks';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useState } from "react";
import { Task, WorkItemStatus } from "@/types/project";
import { useChangeTaskStatus, useUpdateTask, useDeleteTask } from "@/lib/hooks/use-tasks";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
MoreHorizontal,
Pencil,
Trash2,
Clock,
User,
CheckCircle2,
Circle
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { TaskEditDialog } from './task-edit-dialog';
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Pencil, Trash2, Clock, User, CheckCircle2, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
import { TaskEditDialog } from "./task-edit-dialog";
interface TaskCardProps {
task: Task;
@@ -31,17 +23,18 @@ interface TaskCardProps {
}
const priorityColors = {
Critical: 'bg-red-500 text-white',
High: 'bg-orange-500 text-white',
Medium: 'bg-yellow-500 text-white',
Low: 'bg-blue-500 text-white',
Critical: "bg-red-500 text-white",
High: "bg-orange-500 text-white",
Medium: "bg-yellow-500 text-white",
Low: "bg-blue-500 text-white",
};
const statusColors = {
Todo: 'text-gray-500',
InProgress: 'text-blue-500',
Done: 'text-green-500',
Blocked: 'text-red-500',
Backlog: "text-slate-500",
Todo: "text-gray-500",
InProgress: "text-blue-500",
Done: "text-green-500",
Blocked: "text-red-500",
};
export function TaskCard({ task, storyId }: TaskCardProps) {
@@ -51,15 +44,15 @@ export function TaskCard({ task, storyId }: TaskCardProps) {
const updateTask = useUpdateTask();
const deleteTask = useDeleteTask();
const isDone = task.status === 'Done';
const isDone = task.status === "Done";
const handleCheckboxChange = (checked: boolean) => {
const newStatus: WorkItemStatus = checked ? 'Done' : 'Todo';
const newStatus: WorkItemStatus = checked ? "Done" : "Todo";
changeStatus.mutate({ id: task.id, status: newStatus });
};
const handleDelete = () => {
if (confirm('Are you sure you want to delete this task?')) {
if (confirm("Are you sure you want to delete this task?")) {
deleteTask.mutate(task.id);
}
};
@@ -67,7 +60,7 @@ export function TaskCard({ task, storyId }: TaskCardProps) {
return (
<Card
className={cn(
"transition-all duration-200 hover:shadow-md cursor-pointer",
"cursor-pointer transition-all duration-200 hover:shadow-md",
isDone && "opacity-60"
)}
onClick={() => setIsExpanded(!isExpanded)}
@@ -85,51 +78,44 @@ export function TaskCard({ task, storyId }: TaskCardProps) {
</div>
{/* Task Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h4 className={cn(
"font-medium text-sm",
isDone && "line-through text-muted-foreground"
)}>
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center gap-2">
<h4
className={cn(
"text-sm font-medium",
isDone && "text-muted-foreground line-through"
)}
>
{task.title}
</h4>
<Badge
variant="secondary"
className={cn("text-xs", priorityColors[task.priority])}
>
<Badge variant="secondary" className={cn("text-xs", priorityColors[task.priority])}>
{task.priority}
</Badge>
</div>
{/* Metadata */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="text-muted-foreground flex items-center gap-4 text-xs">
{task.estimatedHours && (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<Clock className="h-3 w-3" />
<span>{task.estimatedHours}h</span>
</div>
)}
{task.assigneeId && (
<div className="flex items-center gap-1">
<User className="w-3 h-3" />
<User className="h-3 w-3" />
<span>Assigned</span>
</div>
)}
<div className={cn("flex items-center gap-1", statusColors[task.status])}>
{isDone ? (
<CheckCircle2 className="w-3 h-3" />
) : (
<Circle className="w-3 h-3" />
)}
{isDone ? <CheckCircle2 className="h-3 w-3" /> : <Circle className="h-3 w-3" />}
<span>{task.status}</span>
</div>
</div>
{/* Description (expanded) */}
{isExpanded && task.description && (
<div className="mt-3 text-sm text-muted-foreground">
{task.description}
</div>
<div className="text-muted-foreground mt-3 text-sm">{task.description}</div>
)}
</div>
@@ -137,24 +123,17 @@ export function TaskCard({ task, storyId }: TaskCardProps) {
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
>
<MoreHorizontal className="w-4 h-4" />
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsEditDialogOpen(true)}>
<Pencil className="w-4 h-4 mr-2" />
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDelete}
className="text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
<DropdownMenuItem onClick={handleDelete} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
@@ -164,11 +143,7 @@ export function TaskCard({ task, storyId }: TaskCardProps) {
</CardHeader>
{/* Edit Dialog */}
<TaskEditDialog
task={task}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
/>
<TaskEditDialog task={task} open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} />
</Card>
);
}

View File

@@ -1,14 +1,20 @@
'use client';
"use client";
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useCreateTask } from '@/lib/hooks/use-tasks';
import { CreateTaskDto, WorkItemPriority } from '@/types/project';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useCreateTask } from "@/lib/hooks/use-tasks";
import { CreateTaskDto, WorkItemPriority } from "@/types/project";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
@@ -16,18 +22,18 @@ import {
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Card, CardContent } from '@/components/ui/card';
import { Plus, X } from 'lucide-react';
} from "@/components/ui/form";
import { Card, CardContent } from "@/components/ui/card";
import { Plus, X } from "lucide-react";
interface TaskQuickAddProps {
storyId: string;
}
const taskSchema = z.object({
title: z.string().min(1, 'Title is required').max(200, 'Title too long'),
priority: z.enum(['Critical', 'High', 'Medium', 'Low']),
estimatedHours: z.coerce.number().min(0).optional(),
title: z.string().min(1, "Title is required").max(200, "Title too long"),
priority: z.enum(["Critical", "High", "Medium", "Low"]),
estimatedHours: z.number().min(0).optional().or(z.literal("")),
});
type TaskFormData = z.infer<typeof taskSchema>;
@@ -39,8 +45,8 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
const form = useForm<TaskFormData>({
resolver: zodResolver(taskSchema),
defaultValues: {
title: '',
priority: 'Medium',
title: "",
priority: "Medium",
estimatedHours: undefined,
},
});
@@ -50,7 +56,7 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
storyId,
title: data.title,
priority: data.priority as WorkItemPriority,
estimatedHours: data.estimatedHours,
estimatedHours: typeof data.estimatedHours === "number" ? data.estimatedHours : undefined,
};
createTask.mutate(taskData, {
@@ -68,13 +74,8 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
if (!isOpen) {
return (
<Button
onClick={() => setIsOpen(true)}
variant="outline"
className="w-full"
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
<Button onClick={() => setIsOpen(true)} variant="outline" className="w-full" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Task
</Button>
);
@@ -85,7 +86,7 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
<CardContent className="pt-4">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="flex items-center justify-between mb-2">
<div className="mb-2 flex items-center justify-between">
<h4 className="text-sm font-medium">Quick Add Task</h4>
<Button
type="button"
@@ -94,7 +95,7 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
onClick={handleCancel}
className="h-6 w-6 p-0"
>
<X className="w-4 h-4" />
<X className="h-4 w-4" />
</Button>
</div>
@@ -105,11 +106,7 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
<FormItem>
<FormLabel>Title *</FormLabel>
<FormControl>
<Input
placeholder="e.g., Implement login API"
{...field}
autoFocus
/>
<Input placeholder="e.g., Implement login API" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -123,10 +120,7 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
@@ -154,8 +148,11 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
<Input
type="number"
placeholder="8"
{...field}
value={field.value ?? ''}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === "" ? "" : parseFloat(value));
}}
value={field.value === undefined ? "" : field.value}
/>
</FormControl>
<FormMessage />
@@ -165,20 +162,10 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
</div>
<div className="flex gap-2">
<Button
type="submit"
size="sm"
disabled={createTask.isPending}
className="flex-1"
>
{createTask.isPending ? 'Creating...' : 'Add Task'}
<Button type="submit" size="sm" disabled={createTask.isPending} className="flex-1">
{createTask.isPending ? "Creating..." : "Add Task"}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancel}
>
<Button type="button" variant="outline" size="sm" onClick={handleCancel}>
Cancel
</Button>
</div>