feat(09-02): manage etapes

This commit is contained in:
Arthur Belleville 2026-05-15 22:44:50 +02:00
parent 9b89282692
commit 4af623a57b
No known key found for this signature in database
4 changed files with 334 additions and 22 deletions

View file

@ -9,6 +9,7 @@ import (
"backend/internal/db/sqlc" "backend/internal/db/sqlc"
"backend/templates" "backend/templates"
"github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@ -116,6 +117,42 @@ func parseOwnedEtapeID(r *http.Request, q *sqlc.Queries, tabloID uuid.UUID, raw
return pgtype.UUID{Bytes: etapeID, Valid: true}, true, nil return pgtype.UUID{Bytes: etapeID, Valid: true}, true, nil
} }
func loadOwnedEtape(w http.ResponseWriter, r *http.Request, deps EtapesDeps) (sqlc.Tablo, sqlc.Etape, bool) {
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
if !ok {
return sqlc.Tablo{}, sqlc.Etape{}, false
}
etapeID, err := uuid.Parse(chi.URLParam(r, "etape_id"))
if err != nil {
http.NotFound(w, r)
return sqlc.Tablo{}, sqlc.Etape{}, false
}
etape, err := deps.Queries.GetEtapeByID(r.Context(), sqlc.GetEtapeByIDParams{ID: etapeID, TabloID: tablo.ID})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return sqlc.Tablo{}, sqlc.Etape{}, false
}
slog.Default().Error("etapes: GetEtapeByID failed", "tablo_id", tablo.ID, "etape_id", etapeID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return sqlc.Tablo{}, sqlc.Etape{}, false
}
return tablo, etape, true
}
func renderTasksTab(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) {
tasks, etapes, counts, filter, ok := loadTasksTabData(w, r, q, tablo)
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Retarget", "#tasks-tab")
w.Header().Set("HX-Reswap", "outerHTML")
}
_ = templates.TasksTabFragment(tablo, tasks, etapes, counts, filter, csrf.Token(r)).Render(r.Context(), w)
}
func EtapeNewFormHandler(deps EtapesDeps) http.HandlerFunc { func EtapeNewFormHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}) tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
@ -127,6 +164,130 @@ func EtapeNewFormHandler(deps EtapesDeps) http.HandlerFunc {
} }
} }
func EtapeEditFormHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, etape, ok := loadOwnedEtape(w, r, deps)
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EtapeEditFormFragment(
tablo.ID,
etape,
templates.EtapeUpdateForm{Title: etape.Title, Description: etape.Description.String},
templates.EtapeUpdateErrors{},
csrf.Token(r),
).Render(r.Context(), w)
}
}
func EtapeUpdateHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, etape, ok := loadOwnedEtape(w, r, deps)
if !ok {
return
}
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
title := strings.TrimSpace(r.PostFormValue("title"))
description := strings.TrimSpace(r.PostFormValue("description"))
form := templates.EtapeUpdateForm{Title: title, Description: description}
var errs templates.EtapeUpdateErrors
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")
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Retarget", "#etape-form-slot")
w.Header().Set("HX-Reswap", "innerHTML")
}
w.WriteHeader(http.StatusUnprocessableEntity)
_ = templates.EtapeEditFormFragment(tablo.ID, etape, form, errs, csrf.Token(r)).Render(ctx, w)
return
}
if _, err := deps.Queries.UpdateEtape(ctx, sqlc.UpdateEtapeParams{
ID: etape.ID,
TabloID: tablo.ID,
Title: title,
Description: pgtype.Text{String: description, Valid: description != ""},
}); err != nil {
slog.Default().Error("etapes update: UpdateEtape failed", "tablo_id", tablo.ID, "etape_id", etape.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
renderTasksTab(w, r, deps.Queries, tablo)
}
}
func EtapeDeleteConfirmHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, etape, ok := loadOwnedEtape(w, r, deps)
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EtapeDeleteConfirmFragment(tablo.ID, etape, csrf.Token(r)).Render(r.Context(), w)
}
}
func EtapeDeleteHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, etape, ok := loadOwnedEtape(w, r, deps)
if !ok {
return
}
if err := deps.Queries.DeleteEtape(r.Context(), sqlc.DeleteEtapeParams{ID: etape.ID, TabloID: tablo.ID}); err != nil {
slog.Default().Error("etapes delete: DeleteEtape failed", "tablo_id", tablo.ID, "etape_id", etape.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
renderTasksTab(w, r, deps.Queries, tablo)
}
}
func EtapeReorderHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
for i, rawID := range r.Form["etape_id"] {
etapeID, err := uuid.Parse(rawID)
if err != nil {
continue
}
if _, err := deps.Queries.GetEtapeByID(r.Context(), sqlc.GetEtapeByIDParams{ID: etapeID, TabloID: tablo.ID}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
continue
}
slog.Default().Error("etapes reorder: GetEtapeByID failed", "tablo_id", tablo.ID, "etape_id", etapeID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if _, err := deps.Queries.UpdateEtapePosition(r.Context(), sqlc.UpdateEtapePositionParams{
ID: etapeID,
TabloID: tablo.ID,
Position: int32((i + 1) * 100),
}); err != nil {
slog.Default().Error("etapes reorder: UpdateEtapePosition failed", "tablo_id", tablo.ID, "etape_id", etapeID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
renderTasksTab(w, r, deps.Queries, tablo)
}
}
func EtapeCancelNewHandler(deps EtapesDeps) http.HandlerFunc { func EtapeCancelNewHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if _, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}); !ok { if _, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}); !ok {
@ -186,15 +347,6 @@ func EtapeCreateHandler(deps EtapesDeps) http.HandlerFunc {
return return
} }
tasks, etapes, counts, filter, ok := loadTasksTabData(w, r, deps.Queries, tablo) renderTasksTab(w, r, deps.Queries, tablo)
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Retarget", "#tasks-tab")
w.Header().Set("HX-Reswap", "outerHTML")
}
_ = templates.TasksTabFragment(tablo, tasks, etapes, counts, filter, csrf.Token(r)).Render(ctx, w)
} }
} }

View file

@ -109,6 +109,11 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep
r.Get("/tablos/{id}/etapes/new", EtapeNewFormHandler(etapeDeps)) r.Get("/tablos/{id}/etapes/new", EtapeNewFormHandler(etapeDeps))
r.Get("/tablos/{id}/etapes/cancel-new", EtapeCancelNewHandler(etapeDeps)) r.Get("/tablos/{id}/etapes/cancel-new", EtapeCancelNewHandler(etapeDeps))
r.Post("/tablos/{id}/etapes", EtapeCreateHandler(etapeDeps)) r.Post("/tablos/{id}/etapes", EtapeCreateHandler(etapeDeps))
r.Post("/tablos/{id}/etapes/reorder", EtapeReorderHandler(etapeDeps))
r.Get("/tablos/{id}/etapes/{etape_id}/edit", EtapeEditFormHandler(etapeDeps))
r.Post("/tablos/{id}/etapes/{etape_id}", EtapeUpdateHandler(etapeDeps))
r.Get("/tablos/{id}/etapes/{etape_id}/delete-confirm", EtapeDeleteConfirmHandler(etapeDeps))
r.Post("/tablos/{id}/etapes/{etape_id}/delete", EtapeDeleteHandler(etapeDeps))
// Parametric task routes — must come after static task segments. // Parametric task routes — must come after static task segments.
r.Get("/tablos/{id}/tasks/{task_id}/show", TaskShowHandler(taskDeps)) r.Get("/tablos/{id}/tasks/{task_id}/show", TaskShowHandler(taskDeps))
r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps)) r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps))

View file

@ -33,18 +33,54 @@ templ EtapeStrip(tabloID uuid.UUID, etapes []sqlc.Etape, counts EtapeTaskCounts,
<span>Unassigned</span> <span>Unassigned</span>
<span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">{ strconv.Itoa(counts.Unassigned) }</span> <span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">{ strconv.Itoa(counts.Unassigned) }</span>
</a> </a>
for _, etape := range etapes { for index, etape := range etapes {
<a <div class="inline-flex flex-shrink-0 items-center gap-1">
href={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks?etape=" + etape.ID.String()) } <a
hx-get={ "/tablos/" + tabloID.String() + "/tasks?etape=" + etape.ID.String() } href={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks?etape=" + etape.ID.String()) }
hx-target="#tab-content" hx-get={ "/tablos/" + tabloID.String() + "/tasks?etape=" + etape.ID.String() }
hx-swap="innerHTML" hx-target="#tab-content"
hx-push-url={ "/tablos/" + tabloID.String() + "/tasks?etape=" + etape.ID.String() } hx-swap="innerHTML"
class={ etapeChipClasses(filter.IsEtape(etape.ID)) } hx-push-url={ "/tablos/" + tabloID.String() + "/tasks?etape=" + etape.ID.String() }
> class={ etapeChipClasses(filter.IsEtape(etape.ID)) }
<span>{ etape.Title }</span> >
<span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">{ strconv.Itoa(etapeCount(counts, etape.ID)) }</span> <span>{ etape.Title }</span>
</a> <span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">{ strconv.Itoa(etapeCount(counts, etape.ID)) }</span>
</a>
<button
type="button"
class="ui-button ui-button-soft-neutral-md px-2"
hx-get={ "/tablos/" + tabloID.String() + "/etapes/" + etape.ID.String() + "/edit" }
hx-target="#etape-form-slot"
hx-swap="innerHTML"
aria-label={ "Edit etape: " + etape.Title }
>Edit</button>
<button
type="button"
class="ui-button ui-button-soft-danger-md px-2"
hx-get={ "/tablos/" + tabloID.String() + "/etapes/" + etape.ID.String() + "/delete-confirm" }
hx-target="#etape-form-slot"
hx-swap="innerHTML"
aria-label={ "Delete etape: " + etape.Title }
>Delete</button>
if index > 0 {
<form method="POST" action={ templ.SafeURL("/tablos/" + tabloID.String() + "/etapes/reorder") } hx-post={ "/tablos/" + tabloID.String() + "/etapes/reorder" } hx-target="#tasks-tab" hx-swap="outerHTML" class="inline">
@ui.CSRFField(csrfToken)
for _, id := range etapeReorderIDs(etapes, index, -1) {
<input type="hidden" name="etape_id" value={ id.String() }/>
}
<button type="submit" class="ui-button ui-button-soft-neutral-md px-2" aria-label={ "Move etape earlier: " + etape.Title }>Up</button>
</form>
}
if index < len(etapes)-1 {
<form method="POST" action={ templ.SafeURL("/tablos/" + tabloID.String() + "/etapes/reorder") } hx-post={ "/tablos/" + tabloID.String() + "/etapes/reorder" } hx-target="#tasks-tab" hx-swap="outerHTML" class="inline">
@ui.CSRFField(csrfToken)
for _, id := range etapeReorderIDs(etapes, index, 1) {
<input type="hidden" name="etape_id" value={ id.String() }/>
}
<button type="submit" class="ui-button ui-button-soft-neutral-md px-2" aria-label={ "Move etape later: " + etape.Title }>Down</button>
</form>
}
</div>
} }
<button <button
type="button" type="button"
@ -58,6 +94,100 @@ templ EtapeStrip(tabloID uuid.UUID, etapes []sqlc.Etape, counts EtapeTaskCounts,
</div> </div>
} }
templ EtapeEditFormFragment(tabloID uuid.UUID, etape sqlc.Etape, form EtapeUpdateForm, errs EtapeUpdateErrors, csrfToken string) {
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/etapes/" + etape.ID.String()) }
hx-post={ "/tablos/" + tabloID.String() + "/etapes/" + etape.ID.String() }
hx-target="#tasks-tab"
hx-swap="outerHTML"
class="rounded border border-slate-200 bg-white p-3 shadow-sm space-y-3"
>
@ui.CSRFField(csrfToken)
<div>
<input
type="text"
name="title"
value={ form.Title }
maxlength="255"
required
placeholder="Etape 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="2"
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",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
@ui.Button(ui.ButtonProps{
Label: "Discard",
Variant: ui.ButtonVariantNeutral,
Tone: ui.ButtonToneSoft,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tabloID.String() + "/etapes/cancel-new",
"hx-target": "#etape-form-slot",
"hx-swap": "innerHTML",
},
})
</div>
</form>
}
templ EtapeDeleteConfirmFragment(tabloID uuid.UUID, etape sqlc.Etape, csrfToken string) {
<div class="rounded border border-slate-200 bg-white p-3 shadow-sm space-y-3">
<p class="text-sm font-semibold text-slate-800">Delete etape?</p>
<p class="text-sm text-slate-600">Tasks in this etape will stay in the tablo and move to Unassigned.</p>
<div class="flex items-center gap-2">
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/etapes/" + etape.ID.String() + "/delete") }
hx-post={ "/tablos/" + tabloID.String() + "/etapes/" + etape.ID.String() + "/delete" }
hx-target="#tasks-tab"
hx-swap="outerHTML"
>
@ui.CSRFField(csrfToken)
@ui.Button(ui.ButtonProps{
Label: "Delete etape",
Variant: ui.ButtonVariantDanger,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
</form>
@ui.Button(ui.ButtonProps{
Label: "Keep etape",
Variant: ui.ButtonVariantNeutral,
Tone: ui.ButtonToneSoft,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tabloID.String() + "/etapes/cancel-new",
"hx-target": "#etape-form-slot",
"hx-swap": "innerHTML",
},
})
</div>
</div>
}
templ EtapeCreateFormFragment(tabloID uuid.UUID, form EtapeCreateForm, errs EtapeCreateErrors, csrfToken string) { templ EtapeCreateFormFragment(tabloID uuid.UUID, form EtapeCreateForm, errs EtapeCreateErrors, csrfToken string) {
<form <form
method="POST" method="POST"

View file

@ -3,6 +3,8 @@ package templates
import ( import (
"net/url" "net/url"
"backend/internal/db/sqlc"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -35,6 +37,16 @@ type EtapeCreateErrors struct {
General string General string
} }
type EtapeUpdateForm struct {
Title string
Description string
}
type EtapeUpdateErrors struct {
Title string
General string
}
func (f EtapeFilter) QueryValue() string { func (f EtapeFilter) QueryValue() string {
switch f.Kind { switch f.Kind {
case EtapeFilterUnassigned: case EtapeFilterUnassigned:
@ -86,3 +98,16 @@ func etapeChipClasses(active bool) string {
} }
return base + " border-slate-200 bg-white text-slate-700 hover:border-slate-400" return base + " border-slate-200 bg-white text-slate-700 hover:border-slate-400"
} }
func etapeReorderIDs(etapes []sqlc.Etape, index int, direction int) []uuid.UUID {
ids := make([]uuid.UUID, 0, len(etapes))
for _, etape := range etapes {
ids = append(ids, etape.ID)
}
target := index + direction
if index < 0 || index >= len(ids) || target < 0 || target >= len(ids) {
return ids
}
ids[index], ids[target] = ids[target], ids[index]
return ids
}