From 2b299e21f48434ea2b77179d29041a53a5bcde2e Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 09:37:46 +0200 Subject: [PATCH] feat(04-03): implement TaskEditHandler, TaskUpdateHandler, TaskEditFragment - TaskEditHandler: GET /tablos/{id}/tasks/{task_id}/edit returns TaskEditFragment pre-filled with existing title+description - TaskUpdateHandler: POST validates title (required, max 255), updates title+description preserving status+position (T-04-12) - TaskEditFragment: outer .task-card-zone wrapper with outerHTML round-trip, discard restores via /show - Sortable.js htmx.onLoad init script added to KanbanBoard (Pitfall 2 protection) - TaskEditFragment added to tasks.templ; remove t.Skip from TestTaskUpdate --- backend/internal/web/handlers_tasks.go | 207 +++++++++++++++++++- backend/internal/web/handlers_tasks_test.go | 1 - backend/templates/tasks.templ | 97 +++++++++ 3 files changed, 298 insertions(+), 7 deletions(-) diff --git a/backend/internal/web/handlers_tasks.go b/backend/internal/web/handlers_tasks.go index 5527e17..cb26e96 100644 --- a/backend/internal/web/handlers_tasks.go +++ b/backend/internal/web/handlers_tasks.go @@ -279,25 +279,220 @@ func TaskDeleteHandler(deps TasksDeps) http.HandlerFunc { } // TaskEditHandler handles GET /tablos/{id}/tasks/{task_id}/edit. -// Stub — returns 501 Not Implemented until Plan 03 implements it. +// Returns 200 + TaskEditFragment HTML pre-filled with existing task data (TASK-03). +// Security: loadOwnedTabloForTask verifies ownership; non-owners receive 404. func TaskEditHandler(deps TasksDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "not implemented", http.StatusNotImplemented) + tablo, task, _, ok := loadOwnedTabloForTask(w, r, deps) + if !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TaskEditFragment( + tablo.ID, + task, + templates.TaskUpdateForm{ + Title: task.Title, + Description: task.Description.String, + }, + templates.TaskUpdateErrors{}, + csrf.Token(r), + ).Render(r.Context(), w) } } // TaskUpdateHandler handles POST /tablos/{id}/tasks/{task_id}. -// Stub — returns 501 Not Implemented until Plan 03 implements it. +// Validates title and description, updates title+description in DB, and returns: +// - HTMX: 200 + TaskCard fragment (success) or 422 + TaskEditFragment (validation error) +// - Non-HTMX: 303 redirect to /tablos/{id} +// +// Security invariants: +// - title validated non-empty, max 255 chars (T-04-04) +// - existing Status and Position preserved — only title+description change (T-04-12) +// - tablo+task ownership verified by loadOwnedTabloForTask (T-04-03) func TaskUpdateHandler(deps TasksDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "not implemented", http.StatusNotImplemented) + tablo, task, _, ok := loadOwnedTabloForTask(w, r, deps) + if !ok { + return + } + ctx := r.Context() + + title := strings.TrimSpace(r.PostFormValue("title")) + description := r.PostFormValue("description") + + var errs templates.TaskUpdateErrors + if title == "" { + errs.Title = "Title is required." + } else if len(title) > 255 { + errs.Title = "Title must be 255 characters or fewer." + } + + if errs.Title != "" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusUnprocessableEntity) + if r.Header.Get("HX-Request") == "true" { + _ = templates.TaskEditFragment( + tablo.ID, + task, + templates.TaskUpdateForm{Title: title, Description: description}, + errs, + csrf.Token(r), + ).Render(ctx, w) + return + } + http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther) + return + } + + // Preserve existing Status and Position — only title+description change (T-04-12). + updated, err := deps.Queries.UpdateTask(ctx, sqlc.UpdateTaskParams{ + ID: task.ID, + Title: title, + Description: pgtype.Text{String: description, Valid: description != ""}, + Status: task.Status, + Position: task.Position, + }) + if err != nil { + slog.Default().Error("tasks update: UpdateTask failed", "id", task.ID, "err", err) + errs.General = "Something went wrong. Please try again." + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + if r.Header.Get("HX-Request") == "true" { + _ = templates.TaskEditFragment( + tablo.ID, + task, + templates.TaskUpdateForm{Title: title, Description: description}, + errs, + csrf.Token(r), + ).Render(ctx, w) + return + } + http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther) + return + } + + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TaskCard(tablo.ID, updated, csrf.Token(r)).Render(ctx, w) + return + } + http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther) } } // TaskReorderHandler handles POST /tablos/{id}/tasks/reorder. -// Stub — returns 501 Not Implemented until Plan 03 implements it. +// Reads task_id + task_col (array form fields) to update task status and position in DB, +// then returns the updated KanbanBoard outerHTML. +// +// Security invariants: +// - tablo ownership verified by loadOwnedTablo (T-04-10) +// - each task fetched via GetTaskByID before update — title/description preserved (T-04-08) +// - invalid task UUIDs silently skipped (D-05 last-write-wins) +// - task_col values validated as known TaskStatus via parseTaskStatus (T-04-09) func TaskReorderHandler(deps TasksDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "not implemented", http.StatusNotImplemented) + tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}) + if !ok { + return + } + ctx := r.Context() + + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + taskIDs := r.Form["task_id"] + taskCols := r.Form["task_col"] + + // Support both array-based (Sortable.js) and single-value form submissions. + // Single-value fields ("status" and "position") are used by the existing + // test scaffold — fall back to them when task_col array is absent. + if len(taskCols) == 0 { + // Single-task reorder: task_id + status + optional position. + if len(taskIDs) == 0 { + taskIDs = r.Form["task_id"] + } + statusVal := r.FormValue("status") + positionVal := r.FormValue("position") + taskCols = make([]string, len(taskIDs)) + for i := range taskCols { + taskCols[i] = statusVal + } + + if len(taskIDs) == 1 && positionVal != "" { + // Single-task update with explicit position. + taskID, err := uuid.Parse(taskIDs[0]) + if err == nil { + existing, err := deps.Queries.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ + ID: taskID, + TabloID: tablo.ID, + }) + if err == nil { + newStatus := parseTaskStatus(taskCols[0]) + var newPos int32 + if _, scanErr := fmt.Sscanf(positionVal, "%d", &newPos); scanErr != nil || newPos <= 0 { + newPos = 100 + } + _, _ = deps.Queries.UpdateTask(ctx, sqlc.UpdateTaskParams{ + ID: existing.ID, + Title: existing.Title, + Description: existing.Description, + Status: newStatus, + Position: newPos, + }) + } + } + tasks, _ := deps.Queries.ListTasksByTablo(ctx, tablo.ID) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks).Render(ctx, w) + return + } + } + + if len(taskIDs) != len(taskCols) { + http.Error(w, "mismatched task_id and task_col arrays", http.StatusBadRequest) + return + } + + // Process each task: fetch existing row (T-04-08), update status+position only. + for i, rawID := range taskIDs { + taskID, err := uuid.Parse(rawID) + if err != nil { + // Invalid UUID — skip silently (D-05). + continue + } + newStatus := parseTaskStatus(taskCols[i]) + newPos := int32((i + 1) * 100) + + existing, err := deps.Queries.GetTaskByID(ctx, sqlc.GetTaskByIDParams{ + ID: taskID, + TabloID: tablo.ID, + }) + if err != nil { + // Task not found or belongs to different tablo — skip (T-04-10). + continue + } + + // Preserve title+description; only update status+position (T-04-08). + _, _ = deps.Queries.UpdateTask(ctx, sqlc.UpdateTaskParams{ + ID: existing.ID, + Title: existing.Title, + Description: existing.Description, + Status: newStatus, + Position: newPos, + }) + } + + tasks, err := deps.Queries.ListTasksByTablo(ctx, tablo.ID) + if err != nil { + slog.Default().Error("tasks reorder: ListTasksByTablo failed", "tablo_id", tablo.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks).Render(ctx, w) } } diff --git a/backend/internal/web/handlers_tasks_test.go b/backend/internal/web/handlers_tasks_test.go index af4e66c..218b52c 100644 --- a/backend/internal/web/handlers_tasks_test.go +++ b/backend/internal/web/handlers_tasks_test.go @@ -227,7 +227,6 @@ func TestTaskCreateValidation(t *testing.T) { // TestTaskUpdate verifies that POST /tablos/{id}/tasks/{task_id} updates the // task title and description and returns a card fragment (TASK-03). func TestTaskUpdate(t *testing.T) { - t.Skip("handlers_tasks not yet implemented") pool, cleanup := setupTestDB(t) defer cleanup() diff --git a/backend/templates/tasks.templ b/backend/templates/tasks.templ index 0ea760e..0407169 100644 --- a/backend/templates/tasks.templ +++ b/backend/templates/tasks.templ @@ -37,6 +37,40 @@ templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task) { for _, status := range TaskColumns { @KanbanColumn(tabloID, status, grouped[status], csrfToken) } + } @@ -103,6 +137,69 @@ templ TaskCard(tabloID uuid.UUID, task sqlc.Task, csrfToken string) { } +// TaskEditFragment renders the inline edit form for an existing task. +// The outer wrapper carries class="task-card-zone" id="task-{task.ID}" so +// HTMX outerHTML swaps round-trip cleanly with TaskCard (TASK-03). +// UI-SPEC §3. +templ TaskEditFragment(tabloID uuid.UUID, task sqlc.Task, form TaskUpdateForm, errs TaskUpdateErrors, csrfToken string) { +
+
+ @ui.CSRFField(csrfToken) +
+ + @FieldError(errs.Title) +
+
+ +
+ if errs.General != "" { + @FieldError(errs.General) + } +
+ @ui.Button(ui.ButtonProps{ + Label: "Save changes", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "submit", + }) + @ui.Button(ui.ButtonProps{ + Label: "Discard changes", + Variant: ui.ButtonVariantNeutral, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/show", + "hx-target": "closest .task-card-zone", + "hx-swap": "outerHTML", + }, + }) +
+
+
+} + // TaskCreateFormFragment renders the inline create form shown when a user clicks // "+ Add task". Targets #column-{status} for HTMX beforeend swap on submit. // UI-SPEC §2.