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
This commit is contained in:
Arthur Belleville 2026-05-15 09:37:46 +02:00
parent 5158eb09af
commit 2b299e21f4
No known key found for this signature in database
3 changed files with 298 additions and 7 deletions

View file

@ -279,25 +279,220 @@ func TaskDeleteHandler(deps TasksDeps) http.HandlerFunc {
} }
// TaskEditHandler handles GET /tablos/{id}/tasks/{task_id}/edit. // 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 { func TaskEditHandler(deps TasksDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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}. // 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 { func TaskUpdateHandler(deps TasksDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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. // 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 { func TaskReorderHandler(deps TasksDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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)
} }
} }

View file

@ -227,7 +227,6 @@ func TestTaskCreateValidation(t *testing.T) {
// TestTaskUpdate verifies that POST /tablos/{id}/tasks/{task_id} updates the // TestTaskUpdate verifies that POST /tablos/{id}/tasks/{task_id} updates the
// task title and description and returns a card fragment (TASK-03). // task title and description and returns a card fragment (TASK-03).
func TestTaskUpdate(t *testing.T) { func TestTaskUpdate(t *testing.T) {
t.Skip("handlers_tasks not yet implemented")
pool, cleanup := setupTestDB(t) pool, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()

View file

@ -37,6 +37,40 @@ templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task) {
for _, status := range TaskColumns { for _, status := range TaskColumns {
@KanbanColumn(tabloID, status, grouped[status], csrfToken) @KanbanColumn(tabloID, status, grouped[status], csrfToken)
} }
<script>
htmx.onLoad(function(content) {
var cols = content.querySelectorAll ? content.querySelectorAll(".sortable-column") : [];
if (cols.length === 0 && document) {
cols = document.querySelectorAll(".sortable-column");
}
cols.forEach(function(col) {
new Sortable(col, {
group: "kanban",
animation: 150,
handle: ".task-drag-handle",
draggable: ".task-card",
ghostClass: "bg-slate-100",
chosenClass: "opacity-50",
onEnd: function(evt) {
var form = document.getElementById("reorder-form");
form.querySelectorAll("input[name=task_id],input[name=task_col]").forEach(function(el) { el.remove(); });
document.querySelectorAll(".sortable-column").forEach(function(c) {
c.querySelectorAll(".task-card").forEach(function(card) {
var tid = card.dataset.taskId;
var inp = document.createElement("input");
inp.type = "hidden"; inp.name = "task_id"; inp.value = tid;
form.appendChild(inp);
var col = document.createElement("input");
col.type = "hidden"; col.name = "task_col"; col.value = c.dataset.status;
form.appendChild(col);
});
});
htmx.trigger(form, "submit");
}
});
});
});
</script>
</div> </div>
} }
@ -103,6 +137,69 @@ templ TaskCard(tabloID uuid.UUID, task sqlc.Task, csrfToken string) {
</div> </div>
} }
// 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) {
<div class="task-card-zone" id={ "task-" + task.ID.String() }>
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks/" + task.ID.String()) }
hx-post={ "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() }
hx-target="closest .task-card-zone"
hx-swap="outerHTML"
class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2"
>
@ui.CSRFField(csrfToken)
<div>
<input
type="text"
name="title"
value={ form.Title }
maxlength="255"
required
placeholder="Task title"
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
/>
@FieldError(errs.Title)
</div>
<div>
<textarea
name="description"
rows="3"
placeholder="Description (optional)"
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
>{ form.Description }</textarea>
</div>
if errs.General != "" {
@FieldError(errs.General)
}
<div class="flex items-center gap-2">
@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",
},
})
</div>
</form>
</div>
}
// TaskCreateFormFragment renders the inline create form shown when a user clicks // TaskCreateFormFragment renders the inline create form shown when a user clicks
// "+ Add task". Targets #column-{status} for HTMX beforeend swap on submit. // "+ Add task". Targets #column-{status} for HTMX beforeend swap on submit.
// UI-SPEC §2. // UI-SPEC §2.