fix(09): honor selected etape on task create

This commit is contained in:
Arthur Belleville 2026-05-15 23:46:32 +02:00
parent c5513df987
commit f9fc7a1e34
No known key found for this signature in database
4 changed files with 106 additions and 12 deletions

View file

@ -1,5 +1,5 @@
--- ---
status: complete status: diagnosed
phase: 09-etapes phase: 09-etapes
source: source:
- 09-01-SUMMARY.md - 09-01-SUMMARY.md
@ -7,7 +7,7 @@ source:
- 09-03-SUMMARY.md - 09-03-SUMMARY.md
- 09-04-PLAN.md - 09-04-PLAN.md
started: 2026-05-15T21:38:18Z started: 2026-05-15T21:38:18Z
updated: 2026-05-15T21:44:16Z updated: 2026-05-15T21:46:09Z
--- ---
## Current Test ## Current Test
@ -54,7 +54,16 @@ blocked: 0
reason: "User reported: even when selecting the etape, the created task ends up in the current etape viewed in the etape search param" reason: "User reported: even when selecting the etape, the created task ends up in the current etape viewed in the etape search param"
severity: major severity: major
test: 2 test: 2
root_cause: "" root_cause: "The task create form rendered a hidden etape_id for the active filter before the visible Etape selector. TaskCreateHandler used PostFormValue(\"etape_id\"), which returns the first submitted value, so the active filter could override the user's selected etape."
artifacts: [] artifacts:
missing: [] - path: "backend/templates/tasks.templ"
debug_session: "" issue: "TaskCreateFormFragment emitted duplicate etape_id controls."
- path: "backend/internal/web/handlers_tasks.go"
issue: "TaskCreateHandler read the first etape_id value from duplicated form fields."
- path: "backend/internal/web/handlers_tasks_test.go"
issue: "No regression covered selected etape winning over the active filter."
missing:
- "Remove the hidden etape_id input from the create form."
- "Read the last submitted etape_id defensively when duplicate values are present."
- "Add a regression for creating a task while filtered to one etape but selecting another."
debug_session: "inline:$gsd-verify-work-09/test-2"

View file

@ -174,7 +174,8 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
title := strings.TrimSpace(r.PostFormValue("title")) title := strings.TrimSpace(r.PostFormValue("title"))
statusStr := r.PostFormValue("status") statusStr := r.PostFormValue("status")
status := parseTaskStatus(statusStr) status := parseTaskStatus(statusStr)
etapeID, _, err := parseOwnedEtapeID(r, deps.Queries, tablo.ID, r.PostFormValue("etape_id")) submittedEtapeID := lastPostFormValue(r, "etape_id")
etapeID, _, err := parseOwnedEtapeID(r, deps.Queries, tablo.ID, submittedEtapeID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r) http.NotFound(w, r)
@ -210,7 +211,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
_ = templates.TaskCreateFormFragment( _ = templates.TaskCreateFormFragment(
tablo.ID, tablo.ID,
status, status,
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")}, templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: submittedEtapeID},
errs, errs,
csrf.Token(r), csrf.Token(r),
filter, filter,
@ -236,7 +237,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
_ = templates.TaskCreateFormFragment( _ = templates.TaskCreateFormFragment(
tablo.ID, tablo.ID,
status, status,
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")}, templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: submittedEtapeID},
errs, errs,
csrf.Token(r), csrf.Token(r),
filter, filter,
@ -259,7 +260,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
_ = templates.TaskCreateFormFragment( _ = templates.TaskCreateFormFragment(
tablo.ID, tablo.ID,
status, status,
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")}, templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: submittedEtapeID},
errs, errs,
csrf.Token(r), csrf.Token(r),
filter, filter,
@ -288,7 +289,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
_ = templates.TaskCreateFormFragment( _ = templates.TaskCreateFormFragment(
tablo.ID, tablo.ID,
status, status,
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")}, templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: submittedEtapeID},
errs, errs,
csrf.Token(r), csrf.Token(r),
filter, filter,
@ -316,6 +317,14 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
} }
} }
func lastPostFormValue(r *http.Request, key string) string {
values := r.PostForm[key]
if len(values) == 0 {
return ""
}
return values[len(values)-1]
}
// TaskShowHandler handles GET /tablos/{id}/tasks/{task_id}/show. // TaskShowHandler handles GET /tablos/{id}/tasks/{task_id}/show.
// Returns the TaskCard fragment — used by the cancel paths after edit or delete-confirm. // Returns the TaskCard fragment — used by the cancel paths after edit or delete-confirm.
func TaskShowHandler(deps TasksDeps) http.HandlerFunc { func TaskShowHandler(deps TasksDeps) http.HandlerFunc {

View file

@ -230,6 +230,83 @@ func TestTaskCreateRefreshesEtapeCounts(t *testing.T) {
} }
} }
func TestTaskCreateUsesSelectedEtapeOverActiveFilter(t *testing.T) {
pool, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
q := sqlc.New(pool)
store := auth.NewStore(q)
router := newTaskTestRouter(q, store)
user := preInsertUser(t, ctx, q, "taskcreateselector@example.com", "correct-horse-12")
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
UserID: user.ID,
Title: "Task Selected Etape Tablo",
Description: pgtype.Text{Valid: false},
Color: pgtype.Text{Valid: false},
})
if err != nil {
t.Fatalf("InsertTablo: %v", err)
}
filterEtape, err := q.InsertEtape(ctx, sqlc.InsertEtapeParams{
TabloID: tablo.ID,
Title: "Filtered",
Description: pgtype.Text{Valid: false},
Position: 100,
})
if err != nil {
t.Fatalf("InsertEtape filter: %v", err)
}
selectedEtape, err := q.InsertEtape(ctx, sqlc.InsertEtapeParams{
TabloID: tablo.ID,
Title: "Selected",
Description: pgtype.Text{Valid: false},
Position: 200,
})
if err != nil {
t.Fatalf("InsertEtape selected: %v", err)
}
cookieVal, _, err := store.Create(ctx, user.ID)
if err != nil {
t.Fatalf("store.Create: %v", err)
}
sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: cookieVal}
csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/tasks?etape="+filterEtape.ID.String(), []*http.Cookie{sessionCookie})
form := url.Values{
"title": {"Selected Wins"},
"status": {"todo"},
// Mirrors browser order from the rendered form before this regression:
// hidden active-filter etape_id first, visible selector value second.
"etape_id": {filterEtape.ID.String(), selectedEtape.ID.String()},
"_csrf": {csrfToken},
}
req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/tasks?etape="+filterEtape.ID.String(), strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
for _, c := range csrfCookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("POST task create status = %d; want 200", rec.Code)
}
tasks, err := q.ListTasksByTablo(ctx, tablo.ID)
if err != nil {
t.Fatalf("ListTasksByTablo: %v", err)
}
if len(tasks) != 1 {
t.Fatalf("task count = %d; want 1", len(tasks))
}
if !tasks[0].EtapeID.Valid || tasks[0].EtapeID.Bytes != selectedEtape.ID {
t.Fatalf("task etape_id = %+v; want selected etape %s", tasks[0].EtapeID, selectedEtape.ID)
}
}
// ---- TestTaskCreateValidation (TASK-02) ---- // ---- TestTaskCreateValidation (TASK-02) ----
// TestTaskCreateValidation verifies that POST /tablos/{id}/tasks with an empty // TestTaskCreateValidation verifies that POST /tablos/{id}/tasks with an empty

View file

@ -263,7 +263,6 @@ templ TaskCreateFormFragment(tabloID uuid.UUID, status sqlc.TaskStatus, form Tas
class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2" class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2"
> >
<input type="hidden" name="status" value={ string(status) }/> <input type="hidden" name="status" value={ string(status) }/>
<input type="hidden" name="etape_id" value={ form.EtapeID }/>
@ui.CSRFField(csrfToken) @ui.CSRFField(csrfToken)
<div> <div>
<input <input