feat(09-03): add task etape selector

This commit is contained in:
Arthur Belleville 2026-05-15 22:47:56 +02:00
parent 9f6c7eb044
commit b22d79d972
No known key found for this signature in database
3 changed files with 103 additions and 4 deletions

View file

@ -40,6 +40,19 @@ func parseTaskStatus(s string) sqlc.TaskStatus {
return sqlc.TaskStatusTodo
}
func loadEtapesForTaskForm(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tabloID uuid.UUID) ([]sqlc.Etape, bool) {
etapes, err := q.ListEtapesByTablo(r.Context(), tabloID)
if err != nil {
slog.Default().Error("tasks form: ListEtapesByTablo failed", "tablo_id", tabloID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return nil, false
}
if etapes == nil {
etapes = []sqlc.Etape{}
}
return etapes, true
}
// loadOwnedTabloForTask is the shared preamble for all /tablos/{id}/tasks/{task_id}*
// handlers. It calls loadOwnedTablo for tablo ownership verification, then parses
// the {task_id} URL param and fetches the task (verifying it belongs to the tablo).
@ -85,6 +98,10 @@ func TaskNewFormHandler(deps TasksDeps) http.HandlerFunc {
}
statusStr := r.URL.Query().Get("status")
status := parseTaskStatus(statusStr)
etapes, ok := loadEtapesForTaskForm(w, r, deps.Queries, tablo.ID)
if !ok {
return
}
filter := templates.EtapeFilter{Kind: templates.EtapeFilterAll}
if rawEtape := strings.TrimSpace(r.URL.Query().Get("etape")); rawEtape != "" {
if rawEtape == "unassigned" {
@ -104,6 +121,7 @@ func TaskNewFormHandler(deps TasksDeps) http.HandlerFunc {
templates.TaskCreateErrors{},
csrf.Token(r),
filter,
etapes,
).Render(r.Context(), w)
}
}
@ -169,6 +187,10 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
if etapeID.Valid {
filter = templates.EtapeFilter{Kind: templates.EtapeFilterEtape, EtapeID: uuid.UUID(etapeID.Bytes)}
}
etapes, ok := loadEtapesForTaskForm(w, r, deps.Queries, tablo.ID)
if !ok {
return
}
var errs templates.TaskCreateErrors
if title == "" {
@ -192,6 +214,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
errs,
csrf.Token(r),
filter,
etapes,
).Render(ctx, w)
return
}
@ -217,6 +240,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
errs,
csrf.Token(r),
filter,
etapes,
).Render(ctx, w)
return
}
@ -239,6 +263,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
errs,
csrf.Token(r),
filter,
etapes,
).Render(ctx, w)
return
}
@ -267,6 +292,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc {
errs,
csrf.Token(r),
filter,
etapes,
).Render(ctx, w)
return
}
@ -354,10 +380,15 @@ func TaskEditHandler(deps TasksDeps) http.HandlerFunc {
if !ok {
return
}
etapes, ok := loadEtapesForTaskForm(w, r, deps.Queries, tablo.ID)
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.TaskEditFragment(
tablo.ID,
task,
etapes,
templates.TaskUpdateForm{
Title: task.Title,
Description: task.Description.String,
@ -392,6 +423,19 @@ func TaskUpdateHandler(deps TasksDeps) http.HandlerFunc {
title := strings.TrimSpace(r.PostFormValue("title"))
description := strings.TrimSpace(r.PostFormValue("description"))
etapes, ok := loadEtapesForTaskForm(w, r, deps.Queries, tablo.ID)
if !ok {
return
}
etapeID, _, err := parseOwnedEtapeID(r, deps.Queries, tablo.ID, r.PostFormValue("etape_id"))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return
}
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var errs templates.TaskUpdateErrors
if title == "" {
@ -407,6 +451,7 @@ func TaskUpdateHandler(deps TasksDeps) http.HandlerFunc {
_ = templates.TaskEditFragment(
tablo.ID,
task,
etapes,
templates.TaskUpdateForm{Title: title, Description: description},
errs,
csrf.Token(r),
@ -424,7 +469,7 @@ func TaskUpdateHandler(deps TasksDeps) http.HandlerFunc {
Description: pgtype.Text{String: description, Valid: description != ""},
Status: task.Status,
Position: task.Position,
EtapeID: task.EtapeID,
EtapeID: etapeID,
})
if err != nil {
slog.Default().Error("tasks update: UpdateTask failed", "id", task.ID, "err", err)
@ -435,6 +480,7 @@ func TaskUpdateHandler(deps TasksDeps) http.HandlerFunc {
_ = templates.TaskEditFragment(
tablo.ID,
task,
etapes,
templates.TaskUpdateForm{Title: title, Description: description},
errs,
csrf.Token(r),

View file

@ -170,8 +170,9 @@ templ TaskCard(tabloID uuid.UUID, task sqlc.Task, csrfToken string) {
// 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) {
templ TaskEditFragment(tabloID uuid.UUID, task sqlc.Task, etapes []sqlc.Etape, form TaskUpdateForm, errs TaskUpdateErrors, csrfToken string) {
<div class="task-card-zone" id={ "task-" + task.ID.String() }>
{{ selectedEtapeID := taskEtapeIDString(task.EtapeID) }}
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks/" + task.ID.String()) }
@ -193,6 +194,26 @@ templ TaskEditFragment(tabloID uuid.UUID, task sqlc.Task, form TaskUpdateForm, e
/>
@FieldError(errs.Title)
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Etape</label>
<select
name="etape_id"
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm focus:border-slate-500 focus:outline-none"
>
if selectedEtapeID == "" {
<option value="" selected>No etape</option>
} else {
<option value="">No etape</option>
}
for _, etape := range etapes {
if selectedEtapeID == etape.ID.String() {
<option value={ etape.ID.String() } selected>{ etape.Title }</option>
} else {
<option value={ etape.ID.String() }>{ etape.Title }</option>
}
}
</select>
</div>
<div>
<textarea
name="description"
@ -232,7 +253,7 @@ templ TaskEditFragment(tabloID uuid.UUID, task sqlc.Task, form TaskUpdateForm, e
// 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.
templ TaskCreateFormFragment(tabloID uuid.UUID, status sqlc.TaskStatus, form TaskCreateForm, errs TaskCreateErrors, csrfToken string, filter EtapeFilter) {
templ TaskCreateFormFragment(tabloID uuid.UUID, status sqlc.TaskStatus, form TaskCreateForm, errs TaskCreateErrors, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape) {
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks") }
@ -256,6 +277,26 @@ templ TaskCreateFormFragment(tabloID uuid.UUID, status sqlc.TaskStatus, form Tas
/>
@FieldError(errs.Title)
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Etape</label>
<select
name="etape_id"
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm focus:border-slate-500 focus:outline-none"
>
if form.EtapeID == "" {
<option value="" selected>No etape</option>
} else {
<option value="">No etape</option>
}
for _, etape := range etapes {
if form.EtapeID == etape.ID.String() {
<option value={ etape.ID.String() } selected>{ etape.Title }</option>
} else {
<option value={ etape.ID.String() }>{ etape.Title }</option>
}
}
</select>
</div>
<div class="flex items-center gap-2">
@ui.Button(ui.ButtonProps{
Label: "Save",

View file

@ -1,6 +1,11 @@
package templates
import "backend/internal/db/sqlc"
import (
"backend/internal/db/sqlc"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
// TaskColumns defines the canonical left-to-right column order for the kanban board.
var TaskColumns = []sqlc.TaskStatus{
@ -47,3 +52,10 @@ type TaskUpdateErrors struct {
Description string
General string
}
func taskEtapeIDString(id pgtype.UUID) string {
if !id.Valid {
return ""
}
return uuid.UUID(id.Bytes).String()
}