Spaces:
Runtime error
Runtime error
HonzysClawdbot commited on
fix: remove duplicate task title constraint, improve delete handling and scrolling (#386)
Browse files- Remove duplicate title check from POST /api/tasks (closes #368)\n- Scope recurring tasks duplicate check by project_id\n- Fix task deletion error handling β show errors to user (closes #369)\n- Enable vertical scrolling on Kanban board (closes #376)\n- Refactor nodes route to use RPC via callOpenClawGateway\n- Handle read-only filesystem in gateway config registration\n- Add screenshot-drift CI workflow and guide\n- Docker compose: add host-gateway for reaching host gateway
src/app/api/tasks/route.ts
CHANGED
|
@@ -184,12 +184,9 @@ export async function POST(request: NextRequest) {
|
|
| 184 |
metadata = {}
|
| 185 |
} = body;
|
| 186 |
const normalizedStatus = normalizeTaskCreateStatus(status, assigned_to)
|
| 187 |
-
|
| 188 |
-
//
|
| 189 |
-
const
|
| 190 |
-
if (existingTask) {
|
| 191 |
-
return NextResponse.json({ error: 'Task with this title already exists' }, { status: 409 });
|
| 192 |
-
}
|
| 193 |
|
| 194 |
const now = Math.floor(Date.now() / 1000);
|
| 195 |
const mentionResolution = resolveMentionRecipients(description || '', db, workspaceId);
|
|
@@ -203,7 +200,6 @@ export async function POST(request: NextRequest) {
|
|
| 203 |
const resolvedCompletedAt = completed_at ?? (normalizedStatus === 'done' ? now : null)
|
| 204 |
|
| 205 |
const createTaskTx = db.transaction(() => {
|
| 206 |
-
const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
|
| 207 |
db.prepare(`
|
| 208 |
UPDATE projects
|
| 209 |
SET ticket_counter = ticket_counter + 1, updated_at = unixepoch()
|
|
|
|
| 184 |
metadata = {}
|
| 185 |
} = body;
|
| 186 |
const normalizedStatus = normalizeTaskCreateStatus(status, assigned_to)
|
| 187 |
+
|
| 188 |
+
// Resolve project_id for the task
|
| 189 |
+
const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
const now = Math.floor(Date.now() / 1000);
|
| 192 |
const mentionResolution = resolveMentionRecipients(description || '', db, workspaceId);
|
|
|
|
| 200 |
const resolvedCompletedAt = completed_at ?? (normalizedStatus === 'done' ? now : null)
|
| 201 |
|
| 202 |
const createTaskTx = db.transaction(() => {
|
|
|
|
| 203 |
db.prepare(`
|
| 204 |
UPDATE projects
|
| 205 |
SET ticket_counter = ticket_counter + 1, updated_at = unixepoch()
|
src/components/panels/task-board-panel.tsx
CHANGED
|
@@ -794,7 +794,7 @@ export function TaskBoardPanel() {
|
|
| 794 |
</div>
|
| 795 |
|
| 796 |
{/* Column Body */}
|
| 797 |
-
<div className="flex-1 p-2.5 space-y-2.5 min-h-32 overflow-y-auto">
|
| 798 |
{tasksByStatus[column.key]?.map(task => (
|
| 799 |
<div
|
| 800 |
key={task.id}
|
|
@@ -1253,11 +1253,18 @@ function TaskDetailModal({
|
|
| 1253 |
if (!confirm(t('deleteTaskConfirm', { title: task.title }))) return
|
| 1254 |
try {
|
| 1255 |
const res = await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' })
|
| 1256 |
-
if (!res.ok)
|
| 1257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1258 |
onClose()
|
| 1259 |
-
} catch {
|
| 1260 |
-
//
|
|
|
|
|
|
|
|
|
|
| 1261 |
}
|
| 1262 |
}}
|
| 1263 |
>
|
|
|
|
| 794 |
</div>
|
| 795 |
|
| 796 |
{/* Column Body */}
|
| 797 |
+
<div className="flex-1 p-2.5 space-y-2.5 min-h-32 h-full overflow-y-auto">
|
| 798 |
{tasksByStatus[column.key]?.map(task => (
|
| 799 |
<div
|
| 800 |
key={task.id}
|
|
|
|
| 1253 |
if (!confirm(t('deleteTaskConfirm', { title: task.title }))) return
|
| 1254 |
try {
|
| 1255 |
const res = await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' })
|
| 1256 |
+
if (!res.ok) {
|
| 1257 |
+
const errorData = await res.json().catch(() => ({ error: 'Failed to delete task' }))
|
| 1258 |
+
throw new Error(errorData.error || 'Failed to delete task')
|
| 1259 |
+
}
|
| 1260 |
+
// Close modal immediately on successful deletion
|
| 1261 |
+
// SSE will handle the task.deleted event and remove the task from the UI
|
| 1262 |
onClose()
|
| 1263 |
+
} catch (error) {
|
| 1264 |
+
// Show error to user
|
| 1265 |
+
const errorMessage = error instanceof Error ? error.message : 'Failed to delete task'
|
| 1266 |
+
alert(errorMessage)
|
| 1267 |
+
// Don't close modal on error
|
| 1268 |
}
|
| 1269 |
}}
|
| 1270 |
>
|
src/lib/recurring-tasks.ts
CHANGED
|
@@ -71,12 +71,12 @@ export async function spawnRecurringTasks(): Promise<{ ok: boolean; message: str
|
|
| 71 |
const dateSuffix = formatDateSuffix()
|
| 72 |
const childTitle = `${template.title} - ${dateSuffix}`
|
| 73 |
|
| 74 |
-
// Duplicate prevention: check if a child with this exact title already exists
|
| 75 |
const existing = db.prepare(`
|
| 76 |
SELECT id FROM tasks
|
| 77 |
-
WHERE title = ? AND workspace_id = ?
|
| 78 |
LIMIT 1
|
| 79 |
-
`).get(childTitle, template.workspace_id)
|
| 80 |
if (existing) continue
|
| 81 |
|
| 82 |
// Spawn child task
|
|
|
|
| 71 |
const dateSuffix = formatDateSuffix()
|
| 72 |
const childTitle = `${template.title} - ${dateSuffix}`
|
| 73 |
|
| 74 |
+
// Duplicate prevention: check if a child with this exact title already exists in the same project
|
| 75 |
const existing = db.prepare(`
|
| 76 |
SELECT id FROM tasks
|
| 77 |
+
WHERE title = ? AND workspace_id = ? AND project_id = ?
|
| 78 |
LIMIT 1
|
| 79 |
+
`).get(childTitle, template.workspace_id, template.project_id)
|
| 80 |
if (existing) continue
|
| 81 |
|
| 82 |
// Spawn child task
|
tests/tasks-crud.spec.ts
CHANGED
|
@@ -83,7 +83,7 @@ test.describe('Tasks CRUD', () => {
|
|
| 83 |
expect(res.status()).toBe(400)
|
| 84 |
})
|
| 85 |
|
| 86 |
-
test('POST
|
| 87 |
const { id, body: first } = await createTestTask(request)
|
| 88 |
cleanup.push(id)
|
| 89 |
|
|
@@ -91,7 +91,11 @@ test.describe('Tasks CRUD', () => {
|
|
| 91 |
headers: API_KEY_HEADER,
|
| 92 |
data: { title: first.task.title },
|
| 93 |
})
|
| 94 |
-
expect(res.status()).toBe(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
})
|
| 96 |
|
| 97 |
// ββ GET /api/tasks βββββββββββββββββββββββββββ
|
|
|
|
| 83 |
expect(res.status()).toBe(400)
|
| 84 |
})
|
| 85 |
|
| 86 |
+
test('POST allows duplicate title', async ({ request }) => {
|
| 87 |
const { id, body: first } = await createTestTask(request)
|
| 88 |
cleanup.push(id)
|
| 89 |
|
|
|
|
| 91 |
headers: API_KEY_HEADER,
|
| 92 |
data: { title: first.task.title },
|
| 93 |
})
|
| 94 |
+
expect(res.status()).toBe(201)
|
| 95 |
+
const body = await res.json()
|
| 96 |
+
cleanup.push(body.task.id)
|
| 97 |
+
expect(body.task.title).toBe(first.task.title)
|
| 98 |
+
expect(body.task.id).not.toBe(first.task.id)
|
| 99 |
})
|
| 100 |
|
| 101 |
// ββ GET /api/tasks βββββββββββββββββββββββββββ
|