fix(09): honor selected etape on task create
This commit is contained in:
parent
c5513df987
commit
f9fc7a1e34
4 changed files with 106 additions and 12 deletions
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
status: complete
|
||||
status: diagnosed
|
||||
phase: 09-etapes
|
||||
source:
|
||||
- 09-01-SUMMARY.md
|
||||
|
|
@ -7,7 +7,7 @@ source:
|
|||
- 09-03-SUMMARY.md
|
||||
- 09-04-PLAN.md
|
||||
started: 2026-05-15T21:38:18Z
|
||||
updated: 2026-05-15T21:44:16Z
|
||||
updated: 2026-05-15T21:46:09Z
|
||||
---
|
||||
|
||||
## 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"
|
||||
severity: major
|
||||
test: 2
|
||||
root_cause: ""
|
||||
artifacts: []
|
||||
missing: []
|
||||
debug_session: ""
|
||||
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:
|
||||
- path: "backend/templates/tasks.templ"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -174,7 +174,8 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
|
|||
title := strings.TrimSpace(r.PostFormValue("title"))
|
||||
statusStr := r.PostFormValue("status")
|
||||
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 errors.Is(err, pgx.ErrNoRows) {
|
||||
http.NotFound(w, r)
|
||||
|
|
@ -210,7 +211,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
|
|||
_ = templates.TaskCreateFormFragment(
|
||||
tablo.ID,
|
||||
status,
|
||||
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")},
|
||||
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: submittedEtapeID},
|
||||
errs,
|
||||
csrf.Token(r),
|
||||
filter,
|
||||
|
|
@ -236,7 +237,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
|
|||
_ = templates.TaskCreateFormFragment(
|
||||
tablo.ID,
|
||||
status,
|
||||
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")},
|
||||
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: submittedEtapeID},
|
||||
errs,
|
||||
csrf.Token(r),
|
||||
filter,
|
||||
|
|
@ -259,7 +260,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
|
|||
_ = templates.TaskCreateFormFragment(
|
||||
tablo.ID,
|
||||
status,
|
||||
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")},
|
||||
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: submittedEtapeID},
|
||||
errs,
|
||||
csrf.Token(r),
|
||||
filter,
|
||||
|
|
@ -288,7 +289,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
|
|||
_ = templates.TaskCreateFormFragment(
|
||||
tablo.ID,
|
||||
status,
|
||||
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")},
|
||||
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: submittedEtapeID},
|
||||
errs,
|
||||
csrf.Token(r),
|
||||
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.
|
||||
// Returns the TaskCard fragment — used by the cancel paths after edit or delete-confirm.
|
||||
func TaskShowHandler(deps TasksDeps) http.HandlerFunc {
|
||||
|
|
|
|||
|
|
@ -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 verifies that POST /tablos/{id}/tasks with an empty
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<input type="hidden" name="status" value={ string(status) }/>
|
||||
<input type="hidden" name="etape_id" value={ form.EtapeID }/>
|
||||
@ui.CSRFField(csrfToken)
|
||||
<div>
|
||||
<input
|
||||
|
|
|
|||
Loading…
Reference in a new issue