mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-09 05:07:53 +00:00
feat(US-907): Apply template to existing task
This commit is contained in:
parent
a81dfecadf
commit
f87fe0f8af
7 changed files with 644 additions and 152 deletions
|
|
@ -19,3 +19,6 @@
|
|||
{"type":"task.status_changed","taskId":"task_20260128_9Jlp1m","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_lsPTUFTmn8gW","timestamp":"2026-01-28T02:30:42.734Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_SZlouG1WUBpJ","timestamp":"2026-01-28T02:32:53.102Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_dKzctW","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_v40ijOFUT32J","timestamp":"2026-01-28T02:32:53.111Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_AaRWcX","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_I6-ezwZLEq7a","timestamp":"2026-01-28T02:34:13.758Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"todo","previousStatus":"done","id":"evt_-KkUZ32cY-cL","timestamp":"2026-01-28T02:34:56.532Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_Xad2QSppps2V","timestamp":"2026-01-28T02:36:31.643Z"}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,121 @@
|
|||
[
|
||||
{
|
||||
"id": "activity_1769568075614_5u6dejgb9",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260128_qcy3FJ",
|
||||
"taskTitle": "US-907: Apply template to existing task",
|
||||
"timestamp": "2026-01-28T02:41:15.614Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769568028093_94k9mk402",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260128_qcy3FJ",
|
||||
"taskTitle": "US-907: Apply template to existing task",
|
||||
"timestamp": "2026-01-28T02:40:28.093Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567987042_i1odnhqgj",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260128_qcy3FJ",
|
||||
"taskTitle": "US-907: Apply template to existing task",
|
||||
"timestamp": "2026-01-28T02:39:47.042Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567916278_x73ysfm8g",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260128_qcy3FJ",
|
||||
"taskTitle": "US-907: Apply template to existing task",
|
||||
"timestamp": "2026-01-28T02:38:36.278Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567791644_0ze9p6hxo",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_qcy3FJ",
|
||||
"taskTitle": "US-907: Apply template to existing task",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-28T02:36:31.644Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567696533_qfmr8x62s",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_qcy3FJ",
|
||||
"taskTitle": "US-907: Apply template to existing task",
|
||||
"details": {
|
||||
"from": "done",
|
||||
"status": "todo"
|
||||
},
|
||||
"timestamp": "2026-01-28T02:34:56.533Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567681146_fxg33ratb",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260128_AaRWcX",
|
||||
"taskTitle": "US-909: Enhanced template UI",
|
||||
"timestamp": "2026-01-28T02:34:41.146Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567681136_tobnbsg2u",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260128_dKzctW",
|
||||
"taskTitle": "US-908: Template import/export",
|
||||
"timestamp": "2026-01-28T02:34:41.136Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567681127_rt6kve88l",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260128_9Jlp1m",
|
||||
"taskTitle": "US-906: Agent & dependency defaults",
|
||||
"timestamp": "2026-01-28T02:34:41.127Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567681110_p30y0xvku",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260128_8ro0pA",
|
||||
"taskTitle": "US-905: Multi-task blueprints",
|
||||
"timestamp": "2026-01-28T02:34:41.110Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567681102_e8wwvvm0q",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260128_dvaLco",
|
||||
"taskTitle": "US-904: Template categories",
|
||||
"timestamp": "2026-01-28T02:34:41.102Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567681092_x1p500byz",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260128_nyIFMc",
|
||||
"taskTitle": "US-903: Variable interpolation",
|
||||
"timestamp": "2026-01-28T02:34:41.092Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567681083_7plxvn7o7",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260128_z6OIb_",
|
||||
"taskTitle": "US-902: Subtask templates",
|
||||
"timestamp": "2026-01-28T02:34:41.083Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567681073_70343o38y",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260128_fL0JN1",
|
||||
"taskTitle": "US-901: Enhanced template schema",
|
||||
"timestamp": "2026-01-28T02:34:41.073Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567653758_gip0r8h6y",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_AaRWcX",
|
||||
"taskTitle": "US-909: Enhanced template UI",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "done"
|
||||
},
|
||||
"timestamp": "2026-01-28T02:34:13.758Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769567573111_d3sd566d0",
|
||||
"type": "status_changed",
|
||||
|
|
@ -861,155 +978,5 @@
|
|||
"project": "Sprint 8"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:57:55.365Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769450252743_xx89yq6ne",
|
||||
"type": "task_created",
|
||||
"taskId": "task_20260126_8SKJ8t",
|
||||
"taskTitle": "US-804: Token Usage Tracking",
|
||||
"details": {
|
||||
"type": "code",
|
||||
"priority": "medium",
|
||||
"project": "Sprint 8"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:57:32.743Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769450252689_dejjxuezs",
|
||||
"type": "task_created",
|
||||
"taskId": "task_20260126_aBQTa1",
|
||||
"taskTitle": "US-803: Emit Run Events",
|
||||
"details": {
|
||||
"type": "code",
|
||||
"priority": "high",
|
||||
"project": "Sprint 8"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:57:32.689Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769450252634_w6utau64q",
|
||||
"type": "task_created",
|
||||
"taskId": "task_20260126_kgLjM8",
|
||||
"taskTitle": "US-802: Emit Task Events",
|
||||
"details": {
|
||||
"type": "code",
|
||||
"priority": "high",
|
||||
"project": "Sprint 8"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:57:32.634Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769450237943_armw0090q",
|
||||
"type": "task_created",
|
||||
"taskId": "task_20260126_desyd5",
|
||||
"taskTitle": "US-801: Telemetry Service",
|
||||
"details": {
|
||||
"type": "code",
|
||||
"priority": "high",
|
||||
"project": "Sprint 8"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:57:17.943Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769449288760_hzmmgfxfi",
|
||||
"type": "task_deleted",
|
||||
"taskId": "task_20260126_9VIZY1",
|
||||
"taskTitle": "Test task from CLI",
|
||||
"timestamp": "2026-01-26T17:41:28.760Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769449185820_cctph1fac",
|
||||
"type": "task_deleted",
|
||||
"taskId": "task_20260126_tsDVOq",
|
||||
"taskTitle": "Test feature branch",
|
||||
"timestamp": "2026-01-26T17:39:45.820Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769449175512_6lvnf3bgl",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260126_tsDVOq",
|
||||
"taskTitle": "Test feature branch",
|
||||
"timestamp": "2026-01-26T17:39:35.512Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769448961158_7hmywa016",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260126_9VIZY1",
|
||||
"taskTitle": "Test task from CLI",
|
||||
"details": {
|
||||
"from": "archived",
|
||||
"status": "done"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:36:01.158Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769448955837_m815ex2dh",
|
||||
"type": "task_archived",
|
||||
"taskId": "task_20260126_9VIZY1",
|
||||
"taskTitle": "Test task from CLI",
|
||||
"timestamp": "2026-01-26T17:35:55.837Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769448944900_dm04m4zyu",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260126_9VIZY1",
|
||||
"taskTitle": "Test task from CLI",
|
||||
"details": {
|
||||
"from": "in-progress",
|
||||
"status": "done"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:35:44.900Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769448691266_xbovau2j5",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260126_tsDVOq",
|
||||
"taskTitle": "Test feature branch",
|
||||
"timestamp": "2026-01-26T17:31:31.266Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769448671292_o56r391ij",
|
||||
"type": "task_created",
|
||||
"taskId": "task_20260126_tsDVOq",
|
||||
"taskTitle": "Test feature branch",
|
||||
"details": {
|
||||
"type": "code",
|
||||
"priority": "medium",
|
||||
"project": "test"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:31:11.292Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769448643552_50ghtkm2u",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260126_9VIZY1",
|
||||
"taskTitle": "Test task from CLI",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:30:43.552Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769448480165_c8bcel8bk",
|
||||
"type": "task_created",
|
||||
"taskId": "task_20260126_9VIZY1",
|
||||
"taskTitle": "Test task from CLI",
|
||||
"details": {
|
||||
"type": "code",
|
||||
"priority": "high"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:28:00.165Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769447559137_w5so806no",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260126_PcoH5E",
|
||||
"taskTitle": "US-711: Running indicator on task cards",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "done"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:12:39.137Z"
|
||||
}
|
||||
]
|
||||
|
|
@ -509,6 +509,36 @@ router.get('/:id/worktree/open', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// === Template Application Routes ===
|
||||
|
||||
// POST /api/tasks/:id/apply-template - Apply template to existing task
|
||||
router.post('/:id/apply-template', async (req, res) => {
|
||||
try {
|
||||
const { templateId, templateName, fieldsChanged } = req.body;
|
||||
|
||||
if (!templateId) {
|
||||
return res.status(400).json({ error: 'Template ID is required' });
|
||||
}
|
||||
|
||||
const task = await taskService.getTask(req.params.id);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
|
||||
// Log activity for template application
|
||||
await activityService.logActivity('template_applied', task.id, task.title, {
|
||||
templateId,
|
||||
templateName: templateName || 'Unknown',
|
||||
fieldsChanged: fieldsChanged || [],
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('Error logging template application:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to log template application' });
|
||||
}
|
||||
});
|
||||
|
||||
// === Time Tracking Routes ===
|
||||
|
||||
// POST /api/tasks/:id/time/start - Start timer for a task
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ export type ActivityType =
|
|||
| 'task_deleted'
|
||||
| 'worktree_created'
|
||||
| 'worktree_merged'
|
||||
| 'project_archived';
|
||||
| 'project_archived'
|
||||
| 'template_applied';
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
|
|
|
|||
462
web/src/components/task/ApplyTemplateDialog.tsx
Normal file
462
web/src/components/task/ApplyTemplateDialog.tsx
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useTemplates } from '@/hooks/useTemplates';
|
||||
import { useUpdateTask } from '@/hooks/useTasks';
|
||||
import type { Task, Subtask } from '@veritas-kanban/shared';
|
||||
import { FileCode, AlertCircle, Plus, Minus } from 'lucide-react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
interpolateVariables,
|
||||
extractCustomVariables,
|
||||
type VariableContext
|
||||
} from '@/lib/template-variables';
|
||||
import { getCategoryIcon } from '@/lib/template-categories';
|
||||
|
||||
interface ApplyTemplateDialogProps {
|
||||
task: Task;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
interface MergedField {
|
||||
field: string;
|
||||
label: string;
|
||||
before: string | undefined;
|
||||
after: string;
|
||||
willChange: boolean;
|
||||
}
|
||||
|
||||
interface MergePreview {
|
||||
fields: MergedField[];
|
||||
subtasksAdded: number;
|
||||
existingSubtasks: number;
|
||||
}
|
||||
|
||||
export function ApplyTemplateDialog({ task, open, onOpenChange, onApplied }: ApplyTemplateDialogProps) {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [customVars, setCustomVars] = useState<Record<string, string>>({});
|
||||
const [requiredCustomVars, setRequiredCustomVars] = useState<string[]>([]);
|
||||
const [forceOverwrite, setForceOverwrite] = useState(false);
|
||||
|
||||
const { data: templates } = useTemplates();
|
||||
const updateTask = useUpdateTask();
|
||||
|
||||
// Filter templates by selected category
|
||||
const filteredTemplates = useMemo(() => {
|
||||
if (!templates) return [];
|
||||
if (categoryFilter === 'all') return templates;
|
||||
return templates.filter(t => (t.category || 'custom') === categoryFilter);
|
||||
}, [templates, categoryFilter]);
|
||||
|
||||
// Get the selected template
|
||||
const template = useMemo(() => {
|
||||
if (!selectedTemplate || !templates) return null;
|
||||
return templates.find(t => t.id === selectedTemplate) || null;
|
||||
}, [selectedTemplate, templates]);
|
||||
|
||||
// Calculate merge preview
|
||||
const mergePreview = useMemo((): MergePreview | null => {
|
||||
if (!template) return null;
|
||||
|
||||
// Build variable context
|
||||
const context: VariableContext = {
|
||||
project: task.project,
|
||||
author: 'Brad', // TODO: Get from user config
|
||||
customVars,
|
||||
};
|
||||
|
||||
const fields: MergedField[] = [];
|
||||
|
||||
// Title
|
||||
if (template.taskDefaults.descriptionTemplate) {
|
||||
const interpolatedTitle = task.title;
|
||||
const willChange = forceOverwrite || !task.title;
|
||||
fields.push({
|
||||
field: 'title',
|
||||
label: 'Title',
|
||||
before: task.title,
|
||||
after: interpolatedTitle,
|
||||
willChange,
|
||||
});
|
||||
}
|
||||
|
||||
// Description
|
||||
if (template.taskDefaults.descriptionTemplate) {
|
||||
const interpolatedDescription = interpolateVariables(
|
||||
template.taskDefaults.descriptionTemplate,
|
||||
context
|
||||
);
|
||||
const willChange = forceOverwrite || !task.description;
|
||||
fields.push({
|
||||
field: 'description',
|
||||
label: 'Description',
|
||||
before: task.description,
|
||||
after: interpolatedDescription,
|
||||
willChange,
|
||||
});
|
||||
}
|
||||
|
||||
// Type
|
||||
if (template.taskDefaults.type) {
|
||||
const willChange = forceOverwrite || !task.type;
|
||||
fields.push({
|
||||
field: 'type',
|
||||
label: 'Type',
|
||||
before: task.type,
|
||||
after: template.taskDefaults.type,
|
||||
willChange,
|
||||
});
|
||||
}
|
||||
|
||||
// Priority
|
||||
if (template.taskDefaults.priority) {
|
||||
const willChange = forceOverwrite || !task.priority;
|
||||
fields.push({
|
||||
field: 'priority',
|
||||
label: 'Priority',
|
||||
before: task.priority,
|
||||
after: template.taskDefaults.priority,
|
||||
willChange,
|
||||
});
|
||||
}
|
||||
|
||||
// Project
|
||||
if (template.taskDefaults.project) {
|
||||
const willChange = forceOverwrite || !task.project;
|
||||
fields.push({
|
||||
field: 'project',
|
||||
label: 'Project',
|
||||
before: task.project,
|
||||
after: template.taskDefaults.project,
|
||||
willChange,
|
||||
});
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (template.taskDefaults.tags && template.taskDefaults.tags.length > 0) {
|
||||
const willChange = forceOverwrite || !task.tags || task.tags.length === 0;
|
||||
fields.push({
|
||||
field: 'tags',
|
||||
label: 'Tags',
|
||||
before: task.tags?.join(', '),
|
||||
after: template.taskDefaults.tags.join(', '),
|
||||
willChange,
|
||||
});
|
||||
}
|
||||
|
||||
// Subtasks
|
||||
const subtasksAdded = template.subtaskTemplates?.length || 0;
|
||||
const existingSubtasks = task.subtasks?.length || 0;
|
||||
|
||||
return {
|
||||
fields: fields.filter(f => f.willChange),
|
||||
subtasksAdded,
|
||||
existingSubtasks,
|
||||
};
|
||||
}, [template, task, customVars, forceOverwrite]);
|
||||
|
||||
// Handle template selection
|
||||
const handleTemplateSelect = (templateId: string) => {
|
||||
setSelectedTemplate(templateId);
|
||||
|
||||
const selected = templates?.find(t => t.id === templateId);
|
||||
if (!selected) return;
|
||||
|
||||
// Extract custom variables from description template and subtasks
|
||||
const allTemplateText = [
|
||||
selected.taskDefaults.descriptionTemplate || '',
|
||||
...(selected.subtaskTemplates?.map(st => st.title) || [])
|
||||
].join(' ');
|
||||
|
||||
const customVarNames = extractCustomVariables(allTemplateText);
|
||||
setRequiredCustomVars(customVarNames);
|
||||
|
||||
// Initialize custom vars
|
||||
const initialCustomVars: Record<string, string> = {};
|
||||
customVarNames.forEach(name => {
|
||||
initialCustomVars[name] = '';
|
||||
});
|
||||
setCustomVars(initialCustomVars);
|
||||
};
|
||||
|
||||
// Apply the template
|
||||
const handleApply = async () => {
|
||||
if (!template) return;
|
||||
|
||||
// Build variable context
|
||||
const context: VariableContext = {
|
||||
project: task.project,
|
||||
author: 'Brad', // TODO: Get from user config
|
||||
customVars,
|
||||
};
|
||||
|
||||
// Build update input based on merge strategy
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
// Description
|
||||
if (template.taskDefaults.descriptionTemplate) {
|
||||
const interpolated = interpolateVariables(
|
||||
template.taskDefaults.descriptionTemplate,
|
||||
context
|
||||
);
|
||||
if (forceOverwrite || !task.description) {
|
||||
updates.description = interpolated;
|
||||
}
|
||||
}
|
||||
|
||||
// Type
|
||||
if (template.taskDefaults.type && (forceOverwrite || !task.type)) {
|
||||
updates.type = template.taskDefaults.type;
|
||||
}
|
||||
|
||||
// Priority
|
||||
if (template.taskDefaults.priority && (forceOverwrite || !task.priority)) {
|
||||
updates.priority = template.taskDefaults.priority;
|
||||
}
|
||||
|
||||
// Project
|
||||
if (template.taskDefaults.project && (forceOverwrite || !task.project)) {
|
||||
updates.project = template.taskDefaults.project;
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (template.taskDefaults.tags && template.taskDefaults.tags.length > 0) {
|
||||
if (forceOverwrite || !task.tags || task.tags.length === 0) {
|
||||
updates.tags = template.taskDefaults.tags;
|
||||
}
|
||||
}
|
||||
|
||||
// Subtasks - APPEND to existing
|
||||
if (template.subtaskTemplates && template.subtaskTemplates.length > 0) {
|
||||
const now = new Date().toISOString();
|
||||
const newSubtasks: Subtask[] = template.subtaskTemplates
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(st => ({
|
||||
id: nanoid(),
|
||||
title: interpolateVariables(st.title, context),
|
||||
completed: false,
|
||||
created: now,
|
||||
}));
|
||||
|
||||
// Append to existing subtasks
|
||||
const existingSubtasks = task.subtasks || [];
|
||||
updates.subtasks = [...existingSubtasks, ...newSubtasks];
|
||||
}
|
||||
|
||||
// Apply the updates
|
||||
await updateTask.mutateAsync({
|
||||
id: task.id,
|
||||
input: updates,
|
||||
});
|
||||
|
||||
// Track which fields were changed for activity logging
|
||||
const changedFields = Object.keys(updates);
|
||||
|
||||
// Log activity
|
||||
try {
|
||||
await api.tasks.applyTemplate(
|
||||
task.id,
|
||||
template.id,
|
||||
template.name,
|
||||
changedFields
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to log template application:', error);
|
||||
// Don't fail the whole operation if logging fails
|
||||
}
|
||||
|
||||
// Close dialog and notify parent
|
||||
onOpenChange(false);
|
||||
onApplied?.();
|
||||
|
||||
// Reset state
|
||||
setSelectedTemplate(null);
|
||||
setCustomVars({});
|
||||
setRequiredCustomVars([]);
|
||||
setForceOverwrite(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileCode className="h-5 w-5" />
|
||||
Apply Template to Task
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Template selector */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Select Template</Label>
|
||||
</div>
|
||||
<Tabs value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="all" className="text-xs">All</TabsTrigger>
|
||||
<TabsTrigger value="bug" className="text-xs">🐛</TabsTrigger>
|
||||
<TabsTrigger value="feature" className="text-xs">✨</TabsTrigger>
|
||||
<TabsTrigger value="sprint" className="text-xs">🔄</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Select
|
||||
value={selectedTemplate || 'none'}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'none') {
|
||||
setSelectedTemplate(null);
|
||||
} else {
|
||||
handleTemplateSelect(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a template..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No template selected</SelectItem>
|
||||
{filteredTemplates
|
||||
.filter(t => !t.blueprint) // Exclude blueprint templates
|
||||
.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
{template.category && `${getCategoryIcon(template.category)} `}
|
||||
{template.name}
|
||||
{template.description && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
— {template.description}
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Custom variable inputs */}
|
||||
{requiredCustomVars.length > 0 && (
|
||||
<div className="grid gap-3 border rounded-md p-3 bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-blue-500" />
|
||||
<Label className="text-sm font-medium">Template Variables</Label>
|
||||
</div>
|
||||
{requiredCustomVars.map((varName) => (
|
||||
<div key={varName} className="grid gap-1.5">
|
||||
<Label htmlFor={`var-${varName}`} className="text-xs">
|
||||
{varName}
|
||||
</Label>
|
||||
<Input
|
||||
id={`var-${varName}`}
|
||||
value={customVars[varName] || ''}
|
||||
onChange={(e) => setCustomVars(prev => ({ ...prev, [varName]: e.target.value }))}
|
||||
placeholder={`Enter ${varName}...`}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Force overwrite toggle */}
|
||||
{template && (
|
||||
<div className="flex items-center justify-between border rounded-md p-3 bg-muted/30">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Force Overwrite</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Replace existing values with template values
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={forceOverwrite}
|
||||
onCheckedChange={setForceOverwrite}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Merge preview */}
|
||||
{mergePreview && mergePreview.fields.length > 0 && (
|
||||
<div className="border rounded-md p-3 bg-muted/30 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-blue-500" />
|
||||
<Label className="text-sm font-medium">Changes Preview</Label>
|
||||
</div>
|
||||
|
||||
{mergePreview.fields.map((field) => (
|
||||
<div key={field.field} className="text-sm border-l-2 border-primary/50 pl-3 py-1">
|
||||
<div className="font-medium text-xs text-muted-foreground uppercase">
|
||||
{field.label}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 mt-1">
|
||||
<Minus className="h-3 w-3 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-red-500/80 line-through flex-1">
|
||||
{field.before || '(empty)'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Plus className="h-3 w-3 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-green-500/80 flex-1">
|
||||
{field.after}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{mergePreview.subtasksAdded > 0 && (
|
||||
<div className="text-sm border-l-2 border-primary/50 pl-3 py-1">
|
||||
<div className="font-medium text-xs text-muted-foreground uppercase">
|
||||
Subtasks
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Plus className="h-3 w-3 text-blue-500" />
|
||||
<span className="text-sm">
|
||||
Will add {mergePreview.subtasksAdded} subtasks to existing {mergePreview.existingSubtasks}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!template && (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
Select a template to see what will change
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={!template || updateTask.isPending}
|
||||
>
|
||||
{updateTask.isPending ? 'Applying...' : 'Apply Template'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useUpdateTask, useDeleteTask } from '@/hooks/useTasks';
|
||||
import { Trash2, Code, Search, FileText, Zap, Calendar, Clock, GitBranch, Bot, FileDiff, ClipboardCheck, Monitor } from 'lucide-react';
|
||||
import { Trash2, Code, Search, FileText, Zap, Calendar, Clock, GitBranch, Bot, FileDiff, ClipboardCheck, Monitor, FileCode } from 'lucide-react';
|
||||
import type { Task, TaskType, TaskStatus, TaskPriority, ReviewComment, ReviewState } from '@veritas-kanban/shared';
|
||||
import { GitSection } from './GitSection';
|
||||
import { AgentPanel } from './AgentPanel';
|
||||
|
|
@ -44,6 +44,7 @@ import { SubtasksSection } from './SubtasksSection';
|
|||
import { DependenciesSection } from './DependenciesSection';
|
||||
import { PreviewPanel } from './PreviewPanel';
|
||||
import { TimeTrackingSection } from './TimeTrackingSection';
|
||||
import { ApplyTemplateDialog } from './ApplyTemplateDialog';
|
||||
|
||||
interface TaskDetailPanelProps {
|
||||
task: Task | null;
|
||||
|
|
@ -125,6 +126,7 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro
|
|||
const { localTask, updateField, isDirty } = useDebouncedSave(task, updateTask);
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [applyTemplateOpen, setApplyTemplateOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
|
@ -203,6 +205,15 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro
|
|||
</>
|
||||
)}
|
||||
</TabsList>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setApplyTemplateOpen(true)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<FileCode className="h-3 w-3" />
|
||||
Template
|
||||
</Button>
|
||||
{isCodeTask && localTask.git?.repo && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -432,6 +443,15 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro
|
|||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Apply Template Dialog */}
|
||||
{localTask && (
|
||||
<ApplyTemplateDialog
|
||||
task={localTask}
|
||||
open={applyTemplateOpen}
|
||||
onOpenChange={setApplyTemplateOpen}
|
||||
/>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,15 @@ export const api = {
|
|||
const response = await fetch(`${API_BASE}/tasks/${taskId}/blocking-status`);
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
applyTemplate: async (taskId: string, templateId: string, templateName: string, fieldsChanged: string[]): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/apply-template`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ templateId, templateName, fieldsChanged }),
|
||||
});
|
||||
return handleResponse<void>(response);
|
||||
},
|
||||
},
|
||||
|
||||
config: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue