Fix frontend-backend API field mismatches for Epic entity by: 1. Changed Epic.title to Epic.name in type definitions 2. Added Epic.createdBy field (required by backend) 3. Updated all Epic references from epic.title to epic.name 4. Fixed Epic form to use name field and include createdBy Files modified: - types/project.ts: Updated Epic, CreateEpicDto, UpdateEpicDto interfaces - components/epics/epic-form.tsx: Fixed defaultValues to use epic.name - components/projects/hierarchy-tree.tsx: Replaced epic.title with epic.name - components/projects/story-form.tsx: Fixed epic dropdown to show epic.name - app/(dashboard)/projects/[id]/epics/page.tsx: Display epic.name in list - app/(dashboard)/projects/[id]/page.tsx: Display epic.name in preview - app/(dashboard)/api-test/page.tsx: Display epic.name in test page This resolves the 400 Bad Request error when creating Epics caused by missing 'Name' field (was sending 'title' instead) and missing 'CreatedBy' field. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
270 lines
8.2 KiB
TypeScript
270 lines
8.2 KiB
TypeScript
'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 {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} 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';
|
|
|
|
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']),
|
|
estimatedHours: z
|
|
.number()
|
|
.min(0, 'Estimated hours must be positive')
|
|
.optional()
|
|
.or(z.literal('')),
|
|
});
|
|
|
|
type StoryFormValues = z.infer<typeof storySchema>;
|
|
|
|
interface StoryFormProps {
|
|
story?: Story;
|
|
epicId?: string;
|
|
projectId?: string;
|
|
onSuccess?: () => void;
|
|
onCancel?: () => void;
|
|
}
|
|
|
|
export function StoryForm({
|
|
story,
|
|
epicId,
|
|
projectId,
|
|
onSuccess,
|
|
onCancel,
|
|
}: StoryFormProps) {
|
|
const isEditing = !!story;
|
|
const createStory = useCreateStory();
|
|
const updateStory = useUpdateStory();
|
|
|
|
// Fetch epics for parent epic selection
|
|
const { data: epics = [], isLoading: epicsLoading } = useEpics(projectId);
|
|
|
|
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),
|
|
},
|
|
});
|
|
|
|
async function onSubmit(data: StoryFormValues) {
|
|
try {
|
|
if (isEditing && story) {
|
|
await updateStory.mutateAsync({
|
|
id: story.id,
|
|
data: {
|
|
title: data.title,
|
|
description: data.description,
|
|
priority: data.priority,
|
|
estimatedHours:
|
|
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
|
|
},
|
|
});
|
|
toast.success('Story updated successfully');
|
|
} else {
|
|
await createStory.mutateAsync({
|
|
epicId: data.epicId,
|
|
title: data.title,
|
|
description: data.description,
|
|
priority: data.priority,
|
|
estimatedHours:
|
|
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
|
|
});
|
|
toast.success('Story created successfully');
|
|
}
|
|
onSuccess?.();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Operation failed';
|
|
toast.error(message);
|
|
}
|
|
}
|
|
|
|
const isLoading = createStory.isPending || updateStory.isPending;
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
<FormField
|
|
control={form.control}
|
|
name="epicId"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Parent Epic *</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
defaultValue={field.value}
|
|
disabled={isEditing || !!epicId}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select parent epic" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{epicsLoading ? (
|
|
<div className="p-2 text-sm text-muted-foreground">Loading epics...</div>
|
|
) : epics.length === 0 ? (
|
|
<div className="p-2 text-sm text-muted-foreground">
|
|
No epics available
|
|
</div>
|
|
) : (
|
|
epics.map((epic) => (
|
|
<SelectItem key={epic.id} value={epic.id}>
|
|
{epic.name}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
{isEditing ? 'Parent epic cannot be changed' : 'Select the parent epic'}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="title"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Story Title *</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="e.g., Login page with OAuth support" {...field} />
|
|
</FormControl>
|
|
<FormDescription>A clear, concise title for this story</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="description"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Description</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="Detailed description of the story..."
|
|
className="resize-none"
|
|
rows={6}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Optional detailed description (max 2000 characters)
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="priority"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Priority *</FormLabel>
|
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select priority" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="Low">Low</SelectItem>
|
|
<SelectItem value="Medium">Medium</SelectItem>
|
|
<SelectItem value="High">High</SelectItem>
|
|
<SelectItem value="Critical">Critical</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="estimatedHours"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Estimated Hours</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
placeholder="e.g., 8"
|
|
min="0"
|
|
step="0.5"
|
|
{...field}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
field.onChange(value === '' ? '' : parseFloat(value));
|
|
}}
|
|
value={field.value === undefined ? '' : field.value}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>Optional time estimate</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3">
|
|
{onCancel && (
|
|
<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'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
);
|
|
}
|