diff --git a/backend/internal/web/handlers_etapes.go b/backend/internal/web/handlers_etapes.go index 0ca138a..c0278b5 100644 --- a/backend/internal/web/handlers_etapes.go +++ b/backend/internal/web/handlers_etapes.go @@ -9,6 +9,7 @@ import ( "backend/internal/db/sqlc" "backend/templates" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/gorilla/csrf" "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 } +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 { return func(w http.ResponseWriter, r *http.Request) { 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 { return func(w http.ResponseWriter, r *http.Request) { if _, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}); !ok { @@ -186,15 +347,6 @@ func EtapeCreateHandler(deps EtapesDeps) http.HandlerFunc { return } - tasks, etapes, counts, filter, ok := loadTasksTabData(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) + renderTasksTab(w, r, deps.Queries, tablo) } } diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index 2983755..8e6d00d 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -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/cancel-new", EtapeCancelNewHandler(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. r.Get("/tablos/{id}/tasks/{task_id}/show", TaskShowHandler(taskDeps)) r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps)) diff --git a/backend/templates/etapes.templ b/backend/templates/etapes.templ index f4d6108..d7fa261 100644 --- a/backend/templates/etapes.templ +++ b/backend/templates/etapes.templ @@ -33,18 +33,54 @@ templ EtapeStrip(tabloID uuid.UUID, etapes []sqlc.Etape, counts EtapeTaskCounts, Unassigned { strconv.Itoa(counts.Unassigned) } - for _, etape := range etapes { - { strconv.Itoa(etapeCount(counts, etape.ID)) } - + for index, etape := range etapes { +
+ { strconv.Itoa(etapeCount(counts, etape.ID)) } + + + + if index > 0 { +
+ @ui.CSRFField(csrfToken) + for _, id := range etapeReorderIDs(etapes, index, -1) { + + } + +
+ } + if index < len(etapes)-1 { +
+ @ui.CSRFField(csrfToken) + for _, id := range etapeReorderIDs(etapes, index, 1) { + + } + +
+ } +
}