mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 12:17:50 +00:00
feat(US-603): task templates
- Add TemplateService with markdown file storage - API endpoints for template CRUD - Template selector in create task dialog - Template management in settings - Templates pre-fill type, priority, project, description
This commit is contained in:
parent
bf11dec7d9
commit
ac91ccb83d
8 changed files with 659 additions and 4 deletions
|
|
@ -13,7 +13,7 @@
|
|||
|----|-------|--------|-------|
|
||||
| US-601 | Keyboard shortcuts | ✅ Complete | c, j/k, Enter, Esc, 1-4, ? |
|
||||
| US-602 | Filter and search | ✅ Complete | Project/type dropdowns, text search |
|
||||
| US-603 | Task templates | ⏳ Todo | New from Template option |
|
||||
| US-603 | Task templates | ✅ Complete | New from Template option |
|
||||
| US-604 | Bulk actions | ⏳ Todo | Multi-select, bulk status/archive/delete |
|
||||
| US-605 | Activity log | ⏳ Todo | Toggleable sidebar |
|
||||
|
||||
|
|
@ -44,3 +44,12 @@
|
|||
- Active filter count badge with "Clear all" button
|
||||
- URL persistence (filters sync to query params)
|
||||
- Added Badge UI component
|
||||
|
||||
**US-603: Task templates** ✅
|
||||
- Created TemplateService for server-side template management
|
||||
- Templates stored as markdown files in `.veritas-kanban/templates/`
|
||||
- API endpoints: GET/POST/PATCH/DELETE /api/templates
|
||||
- Template fields: name, description, taskDefaults (type, priority, project, descriptionTemplate)
|
||||
- Template selector in CreateTaskDialog
|
||||
- Template management UI in Settings dialog
|
||||
- Added api.templates methods to api.ts
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { diffRoutes } from './routes/diff.js';
|
|||
import { automationRoutes } from './routes/automation.js';
|
||||
import { summaryRoutes } from './routes/summary.js';
|
||||
import { notificationRoutes } from './routes/notifications.js';
|
||||
import templateRoutes from './routes/templates.js';
|
||||
import type { AgentOutput } from './services/agent-service.js';
|
||||
|
||||
const app = express();
|
||||
|
|
@ -31,6 +32,7 @@ app.use('/api/diff', diffRoutes);
|
|||
app.use('/api/automation', automationRoutes);
|
||||
app.use('/api/summary', summaryRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api/templates', templateRoutes);
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
|
|
|||
103
server/src/routes/templates.ts
Normal file
103
server/src/routes/templates.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { TemplateService } from '../services/template-service.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
const templateService = new TemplateService();
|
||||
|
||||
// Validation schemas
|
||||
const createTemplateSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
taskDefaults: z.object({
|
||||
type: z.enum(['code', 'research', 'content', 'automation']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
descriptionTemplate: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const updateTemplateSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
taskDefaults: z.object({
|
||||
type: z.enum(['code', 'research', 'content', 'automation']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
descriptionTemplate: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
// GET /api/templates - List all templates
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const templates = await templateService.getTemplates();
|
||||
res.json(templates);
|
||||
} catch (error) {
|
||||
console.error('Error listing templates:', error);
|
||||
res.status(500).json({ error: 'Failed to list templates' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/templates/:id - Get single template
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const template = await templateService.getTemplate(req.params.id);
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
res.json(template);
|
||||
} catch (error) {
|
||||
console.error('Error getting template:', error);
|
||||
res.status(500).json({ error: 'Failed to get template' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/templates - Create template
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const input = createTemplateSchema.parse(req.body);
|
||||
const template = await templateService.createTemplate(input);
|
||||
res.status(201).json(template);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: 'Validation failed', details: error.errors });
|
||||
}
|
||||
console.error('Error creating template:', error);
|
||||
res.status(500).json({ error: 'Failed to create template' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/templates/:id - Update template
|
||||
router.patch('/:id', async (req, res) => {
|
||||
try {
|
||||
const input = updateTemplateSchema.parse(req.body);
|
||||
const template = await templateService.updateTemplate(req.params.id, input);
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
res.json(template);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: 'Validation failed', details: error.errors });
|
||||
}
|
||||
console.error('Error updating template:', error);
|
||||
res.status(500).json({ error: 'Failed to update template' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/templates/:id - Delete template
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const deleted = await templateService.deleteTemplate(req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Error deleting template:', error);
|
||||
res.status(500).json({ error: 'Failed to delete template' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
157
server/src/services/template-service.ts
Normal file
157
server/src/services/template-service.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { readdir, readFile, writeFile, unlink, mkdir } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import type { TaskType, TaskPriority } from '@veritas-kanban/shared';
|
||||
|
||||
export interface TaskTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
taskDefaults: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
};
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface CreateTemplateInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
taskDefaults: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateTemplateInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
taskDefaults?: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class TemplateService {
|
||||
private templatesDir: string;
|
||||
|
||||
constructor() {
|
||||
this.templatesDir = join(process.cwd(), '.veritas-kanban', 'templates');
|
||||
this.ensureDir();
|
||||
}
|
||||
|
||||
private async ensureDir() {
|
||||
if (!existsSync(this.templatesDir)) {
|
||||
await mkdir(this.templatesDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
private templatePath(id: string): string {
|
||||
return join(this.templatesDir, `${id}.md`);
|
||||
}
|
||||
|
||||
async getTemplates(): Promise<TaskTemplate[]> {
|
||||
await this.ensureDir();
|
||||
|
||||
const files = await readdir(this.templatesDir);
|
||||
const templates: TaskTemplate[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.md')) continue;
|
||||
|
||||
try {
|
||||
const content = await readFile(join(this.templatesDir, file), 'utf-8');
|
||||
const { data } = matter(content);
|
||||
templates.push(data as TaskTemplate);
|
||||
} catch (err) {
|
||||
console.error(`Error reading template ${file}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return templates.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async getTemplate(id: string): Promise<TaskTemplate | null> {
|
||||
const path = this.templatePath(id);
|
||||
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(path, 'utf-8');
|
||||
const { data } = matter(content);
|
||||
return data as TaskTemplate;
|
||||
} catch (err) {
|
||||
console.error(`Error reading template ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createTemplate(input: CreateTemplateInput): Promise<TaskTemplate> {
|
||||
await this.ensureDir();
|
||||
|
||||
const id = `template_${this.slugify(input.name)}_${Date.now()}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const template: TaskTemplate = {
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
taskDefaults: input.taskDefaults,
|
||||
created: now,
|
||||
updated: now,
|
||||
};
|
||||
|
||||
const content = matter.stringify('', template);
|
||||
await writeFile(this.templatePath(id), content, 'utf-8');
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
async updateTemplate(id: string, input: UpdateTemplateInput): Promise<TaskTemplate | null> {
|
||||
const existing = await this.getTemplate(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const updated: TaskTemplate = {
|
||||
...existing,
|
||||
...input,
|
||||
taskDefaults: {
|
||||
...existing.taskDefaults,
|
||||
...input.taskDefaults,
|
||||
},
|
||||
updated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const content = matter.stringify('', updated);
|
||||
await writeFile(this.templatePath(id), content, 'utf-8');
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteTemplate(id: string): Promise<boolean> {
|
||||
const path = this.templatePath(id);
|
||||
|
||||
if (!existsSync(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await unlink(path);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -34,8 +35,14 @@ import {
|
|||
useUpdateAgents,
|
||||
useSetDefaultAgent,
|
||||
} from '@/hooks/useConfig';
|
||||
import { Plus, Trash2, Check, X, Loader2, FolderGit2, Bot, Star } from 'lucide-react';
|
||||
import type { RepoConfig, AgentConfig, AgentType } from '@veritas-kanban/shared';
|
||||
import {
|
||||
useTemplates,
|
||||
useCreateTemplate,
|
||||
useDeleteTemplate,
|
||||
type TaskTemplate,
|
||||
} from '@/hooks/useTemplates';
|
||||
import { Plus, Trash2, Check, X, Loader2, FolderGit2, Bot, Star, FileText } from 'lucide-react';
|
||||
import type { RepoConfig, AgentConfig, AgentType, TaskType, TaskPriority } from '@veritas-kanban/shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SettingsDialogProps {
|
||||
|
|
@ -219,6 +226,170 @@ function RepoItem({ repo }: { repo: RepoConfig }) {
|
|||
);
|
||||
}
|
||||
|
||||
// Template Components
|
||||
function AddTemplateForm({ onClose }: { onClose: () => void }) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [type, setType] = useState<TaskType | ''>('');
|
||||
const [priority, setPriority] = useState<TaskPriority | ''>('');
|
||||
const [project, setProject] = useState('');
|
||||
const [descriptionTemplate, setDescriptionTemplate] = useState('');
|
||||
|
||||
const createTemplate = useCreateTemplate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
await createTemplate.mutateAsync({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
taskDefaults: {
|
||||
type: type || undefined,
|
||||
priority: priority || undefined,
|
||||
project: project.trim() || undefined,
|
||||
descriptionTemplate: descriptionTemplate.trim() || undefined,
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 border rounded-lg p-4 bg-muted/30">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4" />
|
||||
Add Template
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="template-name">Name *</Label>
|
||||
<Input
|
||||
id="template-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Bug Fix"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="template-desc">Description</Label>
|
||||
<Input
|
||||
id="template-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="e.g., Template for bug fixes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>Default Type</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as TaskType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Any" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="code">Code</SelectItem>
|
||||
<SelectItem value="research">Research</SelectItem>
|
||||
<SelectItem value="content">Content</SelectItem>
|
||||
<SelectItem value="automation">Automation</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Default Priority</Label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Any" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Default Project</Label>
|
||||
<Input
|
||||
value={project}
|
||||
onChange={(e) => setProject(e.target.value)}
|
||||
placeholder="e.g., rubicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Description Template</Label>
|
||||
<Textarea
|
||||
value={descriptionTemplate}
|
||||
onChange={(e) => setDescriptionTemplate(e.target.value)}
|
||||
placeholder="Pre-filled description text..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || createTemplate.isPending}>
|
||||
{createTemplate.isPending ? 'Creating...' : 'Create Template'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateItem({ template }: { template: TaskTemplate }) {
|
||||
const deleteTemplate = useDeleteTemplate();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-md border bg-card">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">{template.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[
|
||||
template.taskDefaults.type,
|
||||
template.taskDefaults.priority,
|
||||
template.taskDefaults.project,
|
||||
].filter(Boolean).join(' • ') || 'No defaults'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete template?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete the "{template.name}" template.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteTemplate.mutate(template.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentItem({
|
||||
agent,
|
||||
isDefault,
|
||||
|
|
@ -279,7 +450,9 @@ function AgentItem({
|
|||
|
||||
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
const { data: config, isLoading } = useConfig();
|
||||
const { data: templates, isLoading: templatesLoading } = useTemplates();
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [showAddTemplateForm, setShowAddTemplateForm] = useState(false);
|
||||
const updateAgents = useUpdateAgents();
|
||||
const setDefaultAgent = useSetDefaultAgent();
|
||||
|
||||
|
|
@ -363,6 +536,41 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Templates Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Task Templates</h3>
|
||||
{!showAddTemplateForm && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAddTemplateForm(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Template
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddTemplateForm && (
|
||||
<AddTemplateForm onClose={() => setShowAddTemplateForm(false)} />
|
||||
)}
|
||||
|
||||
{templatesLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
) : !templates || templates.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center border rounded-md border-dashed">
|
||||
No templates created. Templates pre-fill task fields when creating new tasks.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{templates.map((template) => (
|
||||
<TemplateItem key={template.id} template={template} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useCreateTask } from '@/hooks/useTasks';
|
||||
import { useTemplates, type TaskTemplate } from '@/hooks/useTemplates';
|
||||
import type { TaskType, TaskPriority } from '@veritas-kanban/shared';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -31,8 +33,22 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
const [type, setType] = useState<TaskType>('code');
|
||||
const [priority, setPriority] = useState<TaskPriority>('medium');
|
||||
const [project, setProject] = useState('');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
|
||||
const createTask = useCreateTask();
|
||||
const { data: templates } = useTemplates();
|
||||
|
||||
const applyTemplate = (template: TaskTemplate) => {
|
||||
setSelectedTemplate(template.id);
|
||||
if (template.taskDefaults.type) setType(template.taskDefaults.type);
|
||||
if (template.taskDefaults.priority) setPriority(template.taskDefaults.priority);
|
||||
if (template.taskDefaults.project) setProject(template.taskDefaults.project);
|
||||
if (template.taskDefaults.descriptionTemplate) setDescription(template.taskDefaults.descriptionTemplate);
|
||||
};
|
||||
|
||||
const clearTemplate = () => {
|
||||
setSelectedTemplate(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -53,6 +69,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
setType('code');
|
||||
setPriority('medium');
|
||||
setProject('');
|
||||
setSelectedTemplate(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
|
|
@ -64,6 +81,41 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
<DialogTitle>Create New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Template selector */}
|
||||
{templates && templates.length > 0 && (
|
||||
<div className="flex items-center gap-2 py-2 border-b">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<Select
|
||||
value={selectedTemplate || 'none'}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'none') {
|
||||
clearTemplate();
|
||||
} else {
|
||||
const template = templates.find(t => t.id === value);
|
||||
if (template) applyTemplate(template);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Use a template..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No template</SelectItem>
|
||||
{templates.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
{template.description && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
— {template.description}
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
|
|
|
|||
50
web/src/hooks/useTemplates.ts
Normal file
50
web/src/hooks/useTemplates.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type TaskTemplate, type CreateTemplateInput, type UpdateTemplateInput } from '@/lib/api';
|
||||
|
||||
export type { TaskTemplate, CreateTemplateInput, UpdateTemplateInput };
|
||||
|
||||
export function useTemplates() {
|
||||
return useQuery({
|
||||
queryKey: ['templates'],
|
||||
queryFn: api.templates.list,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTemplate(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['templates', id],
|
||||
queryFn: () => api.templates.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTemplate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.templates.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTemplate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: UpdateTemplateInput }) =>
|
||||
api.templates.update(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTemplate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.templates.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,41 @@
|
|||
import type { Task, CreateTaskInput, UpdateTaskInput, AppConfig, RepoConfig, AgentConfig, AgentType } from '@veritas-kanban/shared';
|
||||
import type { Task, CreateTaskInput, UpdateTaskInput, AppConfig, RepoConfig, AgentConfig, AgentType, TaskType, TaskPriority } from '@veritas-kanban/shared';
|
||||
|
||||
// Template types
|
||||
export interface TaskTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
taskDefaults: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
};
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface CreateTemplateInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
taskDefaults: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateTemplateInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
taskDefaults?: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
|
|
@ -232,6 +269,43 @@ export const api = {
|
|||
return handleResponse<FileDiff[]>(response);
|
||||
},
|
||||
},
|
||||
|
||||
templates: {
|
||||
list: async (): Promise<TaskTemplate[]> => {
|
||||
const response = await fetch(`${API_BASE}/templates`);
|
||||
return handleResponse<TaskTemplate[]>(response);
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<TaskTemplate> => {
|
||||
const response = await fetch(`${API_BASE}/templates/${id}`);
|
||||
return handleResponse<TaskTemplate>(response);
|
||||
},
|
||||
|
||||
create: async (input: CreateTemplateInput): Promise<TaskTemplate> => {
|
||||
const response = await fetch(`${API_BASE}/templates`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return handleResponse<TaskTemplate>(response);
|
||||
},
|
||||
|
||||
update: async (id: string, input: UpdateTemplateInput): Promise<TaskTemplate> => {
|
||||
const response = await fetch(`${API_BASE}/templates/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return handleResponse<TaskTemplate>(response);
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE}/templates/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return handleResponse<void>(response);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Types for diff
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue