From ac91ccb83d49cc358cfd020be5c6c5c33aad4355 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Mon, 26 Jan 2026 04:53:51 -0600 Subject: [PATCH] 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 --- docs/sprint-6.md | 11 +- server/src/index.ts | 2 + server/src/routes/templates.ts | 103 +++++++++ server/src/services/template-service.ts | 157 +++++++++++++ .../components/settings/SettingsDialog.tsx | 212 +++++++++++++++++- web/src/components/task/CreateTaskDialog.tsx | 52 +++++ web/src/hooks/useTemplates.ts | 50 +++++ web/src/lib/api.ts | 76 ++++++- 8 files changed, 659 insertions(+), 4 deletions(-) create mode 100644 server/src/routes/templates.ts create mode 100644 server/src/services/template-service.ts create mode 100644 web/src/hooks/useTemplates.ts diff --git a/docs/sprint-6.md b/docs/sprint-6.md index 848e619d..2b2a64d3 100644 --- a/docs/sprint-6.md +++ b/docs/sprint-6.md @@ -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 diff --git a/server/src/index.ts b/server/src/index.ts index de1883c1..f24c7764 100644 --- a/server/src/index.ts +++ b/server/src/index.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); diff --git a/server/src/routes/templates.ts b/server/src/routes/templates.ts new file mode 100644 index 00000000..c994ac96 --- /dev/null +++ b/server/src/routes/templates.ts @@ -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; diff --git a/server/src/services/template-service.ts b/server/src/services/template-service.ts new file mode 100644 index 00000000..fab80f3b --- /dev/null +++ b/server/src/services/template-service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const path = this.templatePath(id); + + if (!existsSync(path)) { + return false; + } + + await unlink(path); + return true; + } +} diff --git a/web/src/components/settings/SettingsDialog.tsx b/web/src/components/settings/SettingsDialog.tsx index 3a993fc9..84b9e6a2 100644 --- a/web/src/components/settings/SettingsDialog.tsx +++ b/web/src/components/settings/SettingsDialog.tsx @@ -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(''); + const [priority, setPriority] = useState(''); + 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 ( +
+
+ + Add Template +
+ +
+
+
+ + setName(e.target.value)} + placeholder="e.g., Bug Fix" + /> +
+
+ + setDescription(e.target.value)} + placeholder="e.g., Template for bug fixes" + /> +
+
+ +
+
+ + +
+
+ + +
+
+ + setProject(e.target.value)} + placeholder="e.g., rubicon" + /> +
+
+ +
+ +