feat(09-03): add task etape selector
This commit is contained in:
parent
9f6c7eb044
commit
b22d79d972
3 changed files with 103 additions and 4 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue