From 565bb88df5c8def77c701998c0cccb0182aa1234 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 22:40:25 +0200 Subject: [PATCH] feat(09-01): add etape task slice --- backend/cmd/web/main.go | 3 +- backend/internal/db/queries/etapes.sql | 35 ++++ backend/internal/db/queries/tasks.sql | 26 ++- backend/internal/web/csrf_test.go | 2 +- backend/internal/web/handlers_auth_test.go | 6 +- backend/internal/web/handlers_etapes.go | 200 +++++++++++++++++++ backend/internal/web/handlers_files.go | 16 +- backend/internal/web/handlers_files_test.go | 4 +- backend/internal/web/handlers_social_test.go | 2 +- backend/internal/web/handlers_tablos.go | 4 +- backend/internal/web/handlers_tablos_test.go | 3 +- backend/internal/web/handlers_tasks.go | 69 +++++-- backend/internal/web/handlers_tasks_test.go | 2 +- backend/internal/web/handlers_test.go | 6 +- backend/internal/web/router.go | 5 +- backend/migrations/0007_etapes.sql | 27 +++ backend/templates/etapes.templ | 116 +++++++++++ backend/templates/etapes_forms.go | 88 ++++++++ backend/templates/tablos.templ | 11 +- backend/templates/tasks.templ | 21 +- backend/templates/tasks_forms.go | 5 +- 21 files changed, 587 insertions(+), 64 deletions(-) create mode 100644 backend/internal/db/queries/etapes.sql create mode 100644 backend/internal/web/handlers_etapes.go create mode 100644 backend/migrations/0007_etapes.sql create mode 100644 backend/templates/etapes.templ create mode 100644 backend/templates/etapes_forms.go diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index 18dc40a..b7377e3 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -147,9 +147,10 @@ func main() { } fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB} + etapeDeps := web.EtapesDeps{Queries: q} // D-09: pass the embedded static FS — binary has zero runtime file dependencies. - router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, fileDeps, csrfKey, env) + router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, etapeDeps, fileDeps, csrfKey, env) if err != nil { slog.Error("router init failed", "err", err) os.Exit(1) diff --git a/backend/internal/db/queries/etapes.sql b/backend/internal/db/queries/etapes.sql new file mode 100644 index 0000000..de7fc6d --- /dev/null +++ b/backend/internal/db/queries/etapes.sql @@ -0,0 +1,35 @@ +-- name: ListEtapesByTablo :many +SELECT id, tablo_id, title, description, position, created_at, updated_at +FROM etapes +WHERE tablo_id = $1 +ORDER BY position, created_at; + +-- name: InsertEtape :one +INSERT INTO etapes (tablo_id, title, description, position) +VALUES ($1, $2, $3, $4) +RETURNING id, tablo_id, title, description, position, created_at, updated_at; + +-- name: GetEtapeByID :one +SELECT id, tablo_id, title, description, position, created_at, updated_at +FROM etapes +WHERE id = $1 AND tablo_id = $2; + +-- name: UpdateEtape :one +UPDATE etapes +SET title = $3, description = $4, updated_at = now() +WHERE id = $1 AND tablo_id = $2 +RETURNING id, tablo_id, title, description, position, created_at, updated_at; + +-- name: DeleteEtape :exec +DELETE FROM etapes WHERE id = $1 AND tablo_id = $2; + +-- name: MaxEtapePositionByTablo :one +SELECT COALESCE(MAX(position), 0)::integer AS max_position +FROM etapes +WHERE tablo_id = $1; + +-- name: UpdateEtapePosition :one +UPDATE etapes +SET position = $3, updated_at = now() +WHERE id = $1 AND tablo_id = $2 +RETURNING id, tablo_id, title, description, position, created_at, updated_at; diff --git a/backend/internal/db/queries/tasks.sql b/backend/internal/db/queries/tasks.sql index 3896568..2063832 100644 --- a/backend/internal/db/queries/tasks.sql +++ b/backend/internal/db/queries/tasks.sql @@ -1,24 +1,24 @@ -- name: ListTasksByTablo :many -SELECT id, tablo_id, title, description, status, position, created_at, updated_at +SELECT id, tablo_id, title, description, status, position, created_at, updated_at, etape_id FROM tasks WHERE tablo_id = $1 ORDER BY status, position, created_at; -- name: InsertTask :one -INSERT INTO tasks (tablo_id, title, description, status, position) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, tablo_id, title, description, status, position, created_at, updated_at; +INSERT INTO tasks (tablo_id, title, description, status, position, etape_id) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, tablo_id, title, description, status, position, created_at, updated_at, etape_id; -- name: GetTaskByID :one -SELECT id, tablo_id, title, description, status, position, created_at, updated_at +SELECT id, tablo_id, title, description, status, position, created_at, updated_at, etape_id FROM tasks WHERE id = $1 AND tablo_id = $2; -- name: UpdateTask :one UPDATE tasks -SET title = $2, description = $3, status = $4, position = $5, updated_at = now() +SET title = $2, description = $3, status = $4, position = $5, etape_id = $6, updated_at = now() WHERE id = $1 -RETURNING id, tablo_id, title, description, status, position, created_at, updated_at; +RETURNING id, tablo_id, title, description, status, position, created_at, updated_at, etape_id; -- name: DeleteTask :exec DELETE FROM tasks WHERE id = $1 AND tablo_id = $2; @@ -27,3 +27,15 @@ DELETE FROM tasks WHERE id = $1 AND tablo_id = $2; SELECT COALESCE(MAX(position), 0)::integer AS max_position FROM tasks WHERE tablo_id = $1 AND status = $2; + +-- name: ListTasksByTabloAndEtape :many +SELECT id, tablo_id, title, description, status, position, created_at, updated_at, etape_id +FROM tasks +WHERE tablo_id = $1 AND etape_id = $2 +ORDER BY status, position, created_at; + +-- name: ListUnassignedTasksByTablo :many +SELECT id, tablo_id, title, description, status, position, created_at, updated_at, etape_id +FROM tasks +WHERE tablo_id = $1 AND etape_id IS NULL +ORDER BY status, position, created_at; diff --git a/backend/internal/web/csrf_test.go b/backend/internal/web/csrf_test.go index 6bd388b..32fcb6a 100644 --- a/backend/internal/web/csrf_test.go +++ b/backend/internal/web/csrf_test.go @@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler { csrfKey[i] = byte(i + 1) } deps := AuthDeps{Queries: q, Store: store, Secure: false} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost") if err != nil { panic("newTestRouterWithCSRF: " + err.Error()) } diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 2b63bc2..7c61adf 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -36,7 +36,7 @@ var testCSRFKey = func() []byte { // Referer header are accepted. func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newTestRouter: " + err.Error()) } @@ -47,7 +47,7 @@ func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { // enabling rate-limit tests to use a fake clock. func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newTestRouterWithLimiter: " + err.Error()) } @@ -56,7 +56,7 @@ func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.Limit func newAuthPageRouter(t *testing.T, deps AuthDeps) http.Handler { t.Helper() - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/handlers_etapes.go b/backend/internal/web/handlers_etapes.go new file mode 100644 index 0000000..0ca138a --- /dev/null +++ b/backend/internal/web/handlers_etapes.go @@ -0,0 +1,200 @@ +package web + +import ( + "errors" + "log/slog" + "net/http" + "strings" + + "backend/internal/db/sqlc" + "backend/templates" + + "github.com/google/uuid" + "github.com/gorilla/csrf" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type EtapesDeps struct { + Queries *sqlc.Queries +} + +func buildEtapeTaskCounts(tasks []sqlc.Task) templates.EtapeTaskCounts { + counts := templates.EtapeTaskCounts{ + All: len(tasks), + ByEtape: make(map[uuid.UUID]int), + } + for _, task := range tasks { + if task.EtapeID.Valid { + counts.ByEtape[uuid.UUID(task.EtapeID.Bytes)]++ + continue + } + counts.Unassigned++ + } + return counts +} + +func taskHasEtape(task sqlc.Task, id uuid.UUID) bool { + return task.EtapeID.Valid && uuid.UUID(task.EtapeID.Bytes) == id +} + +func loadTasksTabData(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) ([]sqlc.Task, []sqlc.Etape, templates.EtapeTaskCounts, templates.EtapeFilter, bool) { + ctx := r.Context() + etapes, err := q.ListEtapesByTablo(ctx, tablo.ID) + if err != nil { + slog.Default().Error("tasks tab: ListEtapesByTablo failed", "tablo_id", tablo.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, false + } + if etapes == nil { + etapes = []sqlc.Etape{} + } + + allTasks, err := q.ListTasksByTablo(ctx, tablo.ID) + if err != nil { + slog.Default().Error("tasks tab: ListTasksByTablo failed", "tablo_id", tablo.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, false + } + if allTasks == nil { + allTasks = []sqlc.Task{} + } + + counts := buildEtapeTaskCounts(allTasks) + filter := templates.EtapeFilter{Kind: templates.EtapeFilterAll} + tasks := allTasks + rawFilter := strings.TrimSpace(r.URL.Query().Get("etape")) + switch { + case rawFilter == "": + case rawFilter == "unassigned": + filter.Kind = templates.EtapeFilterUnassigned + tasks = tasks[:0] + for _, task := range allTasks { + if !task.EtapeID.Valid { + tasks = append(tasks, task) + } + } + default: + etapeID, err := uuid.Parse(rawFilter) + if err != nil { + http.NotFound(w, r) + return nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, false + } + if _, err := q.GetEtapeByID(ctx, sqlc.GetEtapeByIDParams{ID: etapeID, TabloID: tablo.ID}); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.NotFound(w, r) + return nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, false + } + slog.Default().Error("tasks tab: GetEtapeByID failed", "tablo_id", tablo.ID, "etape_id", etapeID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, false + } + filter = templates.EtapeFilter{Kind: templates.EtapeFilterEtape, EtapeID: etapeID} + tasks = tasks[:0] + for _, task := range allTasks { + if taskHasEtape(task, etapeID) { + tasks = append(tasks, task) + } + } + } + + return tasks, etapes, counts, filter, true +} + +func parseOwnedEtapeID(r *http.Request, q *sqlc.Queries, tabloID uuid.UUID, raw string) (pgtype.UUID, bool, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return pgtype.UUID{}, false, nil + } + etapeID, err := uuid.Parse(raw) + if err != nil { + return pgtype.UUID{}, false, err + } + if _, err := q.GetEtapeByID(r.Context(), sqlc.GetEtapeByIDParams{ID: etapeID, TabloID: tabloID}); err != nil { + return pgtype.UUID{}, false, err + } + return pgtype.UUID{Bytes: etapeID, Valid: true}, true, nil +} + +func EtapeNewFormHandler(deps EtapesDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}) + if !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.EtapeCreateFormFragment(tablo.ID, templates.EtapeCreateForm{}, templates.EtapeCreateErrors{}, csrf.Token(r)).Render(r.Context(), w) + } +} + +func EtapeCancelNewHandler(deps EtapesDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}); !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte("")) + } +} + +func EtapeCreateHandler(deps EtapesDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}) + 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.EtapeCreateForm{Title: title, Description: description} + var errs templates.EtapeCreateErrors + 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.EtapeCreateFormFragment(tablo.ID, form, errs, csrf.Token(r)).Render(ctx, w) + return + } + + maxPos, err := deps.Queries.MaxEtapePositionByTablo(ctx, tablo.ID) + if err != nil { + slog.Default().Error("etapes create: MaxEtapePositionByTablo failed", "tablo_id", tablo.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if _, err := deps.Queries.InsertEtape(ctx, sqlc.InsertEtapeParams{ + TabloID: tablo.ID, + Title: title, + Description: pgtype.Text{String: description, Valid: description != ""}, + Position: maxPos + 100, + }); err != nil { + slog.Default().Error("etapes create: InsertEtape failed", "tablo_id", tablo.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + 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) + } +} diff --git a/backend/internal/web/handlers_files.go b/backend/internal/web/handlers_files.go index de64dac..32f65b8 100644 --- a/backend/internal/web/handlers_files.go +++ b/backend/internal/web/handlers_files.go @@ -95,7 +95,7 @@ func TabloFilesTabHandler(deps FilesDeps) http.HandlerFunc { _ = templates.FilesTabFragment(tablo, fileList, csrf.Token(r)).Render(r.Context(), w) return } - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, fileList, "files").Render(r.Context(), w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, fileList, "files").Render(r.Context(), w) } } @@ -109,21 +109,17 @@ func TabloTasksTabHandler(deps FilesDeps) http.HandlerFunc { if !ok { return } - tasks, err := deps.Queries.ListTasksByTablo(r.Context(), tablo.ID) - if err != nil { - slog.Default().Error("tasks tab: ListTasksByTablo failed", "tablo_id", tablo.ID, "err", err) - tasks = []sqlc.Task{} - } - if tasks == nil { - tasks = []sqlc.Task{} + 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" { - _ = templates.TasksTabFragment(tablo, tasks, csrf.Token(r)).Render(r.Context(), w) + _ = templates.TasksTabFragment(tablo, tasks, etapes, counts, filter, csrf.Token(r)).Render(r.Context(), w) return } - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, "tasks").Render(r.Context(), w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, etapes, counts, filter, nil, "tasks").Render(r.Context(), w) } } diff --git a/backend/internal/web/handlers_files_test.go b/backend/internal/web/handlers_files_test.go index 0217945..8067b03 100644 --- a/backend/internal/web/handlers_files_test.go +++ b/backend/internal/web/handlers_files_test.go @@ -60,7 +60,7 @@ func newFileTestRouter(q *sqlc.Queries, store *auth.Store, fileStore files.FileS tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") if err != nil { panic("newFileTestRouter: " + err.Error()) } @@ -169,7 +169,7 @@ func TestFileUploadTooLarge(t *testing.T) { tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/handlers_social_test.go b/backend/internal/web/handlers_social_test.go index 835f897..a2ca4cf 100644 --- a/backend/internal/web/handlers_social_test.go +++ b/backend/internal/web/handlers_social_test.go @@ -72,7 +72,7 @@ func withGoogleClaims(deps AuthDeps, claims auth.ProviderClaims) AuthDeps { func newSocialRouter(t *testing.T, deps AuthDeps) http.Handler { t.Helper() - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/handlers_tablos.go b/backend/internal/web/handlers_tablos.go index 21d2a25..2c89b4e 100644 --- a/backend/internal/web/handlers_tablos.go +++ b/backend/internal/web/handlers_tablos.go @@ -202,7 +202,7 @@ func TabloDetailHandler(deps TablosDeps) http.HandlerFunc { tasks = []sqlc.Task{} } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, "overview").Render(r.Context(), w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, "overview").Render(r.Context(), w) } } @@ -308,7 +308,7 @@ func TabloUpdateHandler(deps TablosDeps) http.HandlerFunc { if tasks == nil { tasks = []sqlc.Task{} } - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, "overview").Render(ctx, w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, "overview").Render(ctx, w) return } diff --git a/backend/internal/web/handlers_tablos_test.go b/backend/internal/web/handlers_tablos_test.go index 7d6641f..c4adc62 100644 --- a/backend/internal/web/handlers_tablos_test.go +++ b/backend/internal/web/handlers_tablos_test.go @@ -27,7 +27,7 @@ import ( func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newTabloTestRouter: " + err.Error()) } @@ -63,7 +63,6 @@ func loginUser(t *testing.T, router http.Handler, email, password string) []*htt return allCookies } - // ---- TestTabloList ---- // TestTabloList verifies that an authenticated GET / returns the user's tablos diff --git a/backend/internal/web/handlers_tasks.go b/backend/internal/web/handlers_tasks.go index 74d7287..d23bd4e 100644 --- a/backend/internal/web/handlers_tasks.go +++ b/backend/internal/web/handlers_tasks.go @@ -85,14 +85,25 @@ func TaskNewFormHandler(deps TasksDeps) http.HandlerFunc { } statusStr := r.URL.Query().Get("status") status := parseTaskStatus(statusStr) + filter := templates.EtapeFilter{Kind: templates.EtapeFilterAll} + if rawEtape := strings.TrimSpace(r.URL.Query().Get("etape")); rawEtape != "" { + if rawEtape == "unassigned" { + filter.Kind = templates.EtapeFilterUnassigned + } else if etapeID, err := uuid.Parse(rawEtape); err == nil { + if _, err := deps.Queries.GetEtapeByID(r.Context(), sqlc.GetEtapeByIDParams{ID: etapeID, TabloID: tablo.ID}); err == nil { + filter = templates.EtapeFilter{Kind: templates.EtapeFilterEtape, EtapeID: etapeID} + } + } + } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.TaskCreateFormFragment( tablo.ID, status, - templates.TaskCreateForm{}, + templates.TaskCreateForm{EtapeID: filter.TaskEtapeIDValue()}, templates.TaskCreateErrors{}, csrf.Token(r), + filter, ).Render(r.Context(), w) } } @@ -107,9 +118,17 @@ func TaskCancelNewHandler(deps TasksDeps) http.HandlerFunc { } statusStr := r.URL.Query().Get("status") status := parseTaskStatus(statusStr) + filter := templates.EtapeFilter{Kind: templates.EtapeFilterAll} + if rawEtape := strings.TrimSpace(r.URL.Query().Get("etape")); rawEtape == "unassigned" { + filter.Kind = templates.EtapeFilterUnassigned + } else if rawEtape != "" { + if etapeID, err := uuid.Parse(rawEtape); err == nil { + filter = templates.EtapeFilter{Kind: templates.EtapeFilterEtape, EtapeID: etapeID} + } + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.AddTaskTrigger(tablo.ID, status, csrf.Token(r)).Render(r.Context(), w) + _ = templates.AddTaskTrigger(tablo.ID, status, csrf.Token(r), filter).Render(r.Context(), w) } } @@ -137,6 +156,19 @@ 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")) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.NotFound(w, r) + return + } + http.Error(w, "bad request", http.StatusBadRequest) + return + } + filter := templates.EtapeFilter{Kind: templates.EtapeFilterAll} + if etapeID.Valid { + filter = templates.EtapeFilter{Kind: templates.EtapeFilterEtape, EtapeID: uuid.UUID(etapeID.Bytes)} + } var errs templates.TaskCreateErrors if title == "" { @@ -156,9 +188,10 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { _ = templates.TaskCreateFormFragment( tablo.ID, status, - templates.TaskCreateForm{Title: title, Status: statusStr}, + templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")}, errs, csrf.Token(r), + filter, ).Render(ctx, w) return } @@ -180,9 +213,10 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { _ = templates.TaskCreateFormFragment( tablo.ID, status, - templates.TaskCreateForm{Title: title, Status: statusStr}, + templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")}, errs, csrf.Token(r), + filter, ).Render(ctx, w) return } @@ -201,9 +235,10 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { _ = templates.TaskCreateFormFragment( tablo.ID, status, - templates.TaskCreateForm{Title: title, Status: statusStr}, + templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")}, errs, csrf.Token(r), + filter, ).Render(ctx, w) return } @@ -217,6 +252,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { Description: pgtype.Text{Valid: false}, Status: status, Position: maxPos + 100, + EtapeID: etapeID, }) if err != nil { slog.Default().Error("tasks create: InsertTask failed", "tablo_id", tablo.ID, "err", err) @@ -227,9 +263,10 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { _ = templates.TaskCreateFormFragment( tablo.ID, status, - templates.TaskCreateForm{Title: title, Status: statusStr}, + templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")}, errs, csrf.Token(r), + filter, ).Render(ctx, w) return } @@ -242,7 +279,7 @@ func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("HX-Reswap", "beforeend") w.Header().Set("HX-Retarget", "#column-"+string(status)) - _ = templates.TaskCardOOB(status, task, tablo.ID, csrf.Token(r)).Render(ctx, w) + _ = templates.TaskCardOOB(status, task, tablo.ID, csrf.Token(r), filter).Render(ctx, w) return } http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther) @@ -387,6 +424,7 @@ func TaskUpdateHandler(deps TasksDeps) http.HandlerFunc { Description: pgtype.Text{String: description, Valid: description != ""}, Status: task.Status, Position: task.Position, + EtapeID: task.EtapeID, }) if err != nil { slog.Default().Error("tasks update: UpdateTask failed", "id", task.ID, "err", err) @@ -476,6 +514,7 @@ func TaskReorderHandler(deps TasksDeps) http.HandlerFunc { Description: existing.Description, Status: newStatus, Position: newPos, + EtapeID: existing.EtapeID, }); err != nil { slog.Default().Error("tasks reorder: UpdateTask failed (single)", "task_id", existing.ID, "err", err) @@ -484,9 +523,12 @@ func TaskReorderHandler(deps TasksDeps) http.HandlerFunc { } } } - tasks, _ := deps.Queries.ListTasksByTablo(ctx, tablo.ID) + tasks, _, _, filter, ok := loadTasksTabData(w, r, deps.Queries, tablo) + if !ok { + return + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks).Render(ctx, w) + _ = templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter).Render(ctx, w) return } } @@ -522,6 +564,7 @@ func TaskReorderHandler(deps TasksDeps) http.HandlerFunc { Description: existing.Description, Status: newStatus, Position: newPos, + EtapeID: existing.EtapeID, }); err != nil { slog.Default().Error("tasks reorder: UpdateTask failed", "task_id", existing.ID, "err", err) @@ -530,14 +573,12 @@ func TaskReorderHandler(deps TasksDeps) http.HandlerFunc { } } - tasks, err := deps.Queries.ListTasksByTablo(ctx, tablo.ID) - if err != nil { - slog.Default().Error("tasks reorder: ListTasksByTablo failed", "tablo_id", tablo.ID, "err", err) - http.Error(w, "internal server error", http.StatusInternalServerError) + tasks, _, _, filter, ok := loadTasksTabData(w, r, deps.Queries, tablo) + if !ok { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks).Render(ctx, w) + _ = templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter).Render(ctx, w) } } diff --git a/backend/internal/web/handlers_tasks_test.go b/backend/internal/web/handlers_tasks_test.go index 1015c33..5785112 100644 --- a/backend/internal/web/handlers_tasks_test.go +++ b/backend/internal/web/handlers_tasks_test.go @@ -30,7 +30,7 @@ func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newTaskTestRouter: " + err.Error()) } diff --git a/backend/internal/web/handlers_test.go b/backend/internal/web/handlers_test.go index dfe9c25..41067a9 100644 --- a/backend/internal/web/handlers_test.go +++ b/backend/internal/web/handlers_test.go @@ -92,7 +92,7 @@ func TestReadyz_Down(t *testing.T) { // was public. The HTMX demo content is tested by // TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go. func TestIndex_UnauthRedirects(t *testing.T) { - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } @@ -110,7 +110,7 @@ func TestIndex_UnauthRedirects(t *testing.T) { } func TestDemoTime_Fragment(t *testing.T) { - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } @@ -136,7 +136,7 @@ func TestDemoTime_Fragment(t *testing.T) { } func TestRequestID_HeaderSet(t *testing.T) { - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index 9f9ddad..2983755 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -48,7 +48,7 @@ type Pinger interface { // trustedOrigins is an optional list of additional origins for the CSRF // referer check (used in integration tests to allow localhost requests without // a Referer header). In production, pass no extra args — leave empty. -func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error) { +func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, etapeDeps EtapesDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error) { r := chi.NewRouter() r.Use(RequestIDMiddleware) r.Use(chimw.RealIP) @@ -106,6 +106,9 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep r.Get("/tablos/{id}/tasks/cancel-new", TaskCancelNewHandler(taskDeps)) r.Post("/tablos/{id}/tasks", TaskCreateHandler(taskDeps)) r.Post("/tablos/{id}/tasks/reorder", TaskReorderHandler(taskDeps)) + r.Get("/tablos/{id}/etapes/new", EtapeNewFormHandler(etapeDeps)) + r.Get("/tablos/{id}/etapes/cancel-new", EtapeCancelNewHandler(etapeDeps)) + r.Post("/tablos/{id}/etapes", EtapeCreateHandler(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/migrations/0007_etapes.sql b/backend/migrations/0007_etapes.sql new file mode 100644 index 0000000..66140a1 --- /dev/null +++ b/backend/migrations/0007_etapes.sql @@ -0,0 +1,27 @@ +-- migrations/0007_etapes.sql +-- Phase 9: Etapes + +-- +goose Up + +CREATE TABLE etapes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tablo_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + title text NOT NULL, + description text, + position integer NOT NULL DEFAULT 100, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX etapes_tablo_id_position_idx ON etapes(tablo_id, position); + +ALTER TABLE tasks + ADD COLUMN etape_id uuid REFERENCES etapes(id) ON DELETE SET NULL; + +CREATE INDEX tasks_tablo_id_etape_id_idx ON tasks(tablo_id, etape_id); + +-- +goose Down + +DROP INDEX IF EXISTS tasks_tablo_id_etape_id_idx; +ALTER TABLE tasks DROP COLUMN IF EXISTS etape_id; +DROP TABLE IF EXISTS etapes; diff --git a/backend/templates/etapes.templ b/backend/templates/etapes.templ new file mode 100644 index 0000000..f4d6108 --- /dev/null +++ b/backend/templates/etapes.templ @@ -0,0 +1,116 @@ +package templates + +import ( + "backend/internal/db/sqlc" + "backend/internal/web/ui" + "strconv" + + "github.com/google/uuid" +) + +templ EtapeStrip(tabloID uuid.UUID, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, csrfToken string) { +
+ +
+
+} + +templ EtapeCreateFormFragment(tabloID uuid.UUID, form EtapeCreateForm, errs EtapeCreateErrors, csrfToken string) { +
+ @ui.CSRFField(csrfToken) +
+ + @FieldError(errs.Title) +
+
+ +
+ if errs.General != "" { + @FieldError(errs.General) + } +
+ @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", + }, + }) +
+
+} diff --git a/backend/templates/etapes_forms.go b/backend/templates/etapes_forms.go new file mode 100644 index 0000000..66fc965 --- /dev/null +++ b/backend/templates/etapes_forms.go @@ -0,0 +1,88 @@ +package templates + +import ( + "net/url" + + "github.com/google/uuid" +) + +type EtapeFilterKind string + +const ( + EtapeFilterAll EtapeFilterKind = "all" + EtapeFilterUnassigned EtapeFilterKind = "unassigned" + EtapeFilterEtape EtapeFilterKind = "etape" +) + +type EtapeFilter struct { + Kind EtapeFilterKind + EtapeID uuid.UUID +} + +type EtapeTaskCounts struct { + All int + Unassigned int + ByEtape map[uuid.UUID]int +} + +type EtapeCreateForm struct { + Title string + Description string +} + +type EtapeCreateErrors struct { + Title string + General string +} + +func (f EtapeFilter) QueryValue() string { + switch f.Kind { + case EtapeFilterUnassigned: + return "unassigned" + case EtapeFilterEtape: + return f.EtapeID.String() + default: + return "" + } +} + +func (f EtapeFilter) QuerySuffix() string { + if value := f.QueryValue(); value != "" { + return "&etape=" + url.QueryEscape(value) + } + return "" +} + +func (f EtapeFilter) TaskEtapeIDValue() string { + if f.Kind == EtapeFilterEtape { + return f.EtapeID.String() + } + return "" +} + +func (f EtapeFilter) IsAll() bool { + return f.Kind == "" || f.Kind == EtapeFilterAll +} + +func (f EtapeFilter) IsUnassigned() bool { + return f.Kind == EtapeFilterUnassigned +} + +func (f EtapeFilter) IsEtape(id uuid.UUID) bool { + return f.Kind == EtapeFilterEtape && f.EtapeID == id +} + +func etapeCount(counts EtapeTaskCounts, id uuid.UUID) int { + if counts.ByEtape == nil { + return 0 + } + return counts.ByEtape[id] +} + +func etapeChipClasses(active bool) string { + base := "inline-flex items-center gap-2 rounded border px-3 py-1.5 text-sm whitespace-nowrap" + if active { + return base + " border-slate-800 bg-slate-900 text-white" + } + return base + " border-slate-200 bg-white text-slate-700 hover:border-slate-400" +} diff --git a/backend/templates/tablos.templ b/backend/templates/tablos.templ index 596ee57..afd98f2 100644 --- a/backend/templates/tablos.templ +++ b/backend/templates/tablos.templ @@ -171,7 +171,7 @@ templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) { // files and tasks are pre-fetched slices for the active tab (may be nil for inactive tabs). // UI-SPEC §3 Interaction Contract — GET /tablos/{id}. // D-07: signature includes activeTab string param; D-08: tab bar links carry hx-push-url. -templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, files []sqlc.TabloFile, activeTab string) { +templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, files []sqlc.TabloFile, activeTab string) { @Layout("Tablos — Xtablo", user, csrfToken) {
← Back to tablos @@ -227,7 +227,7 @@ templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks
if activeTab == "tasks" { - @TasksTabFragment(tablo, tasks, csrfToken) + @TasksTabFragment(tablo, tasks, etapes, counts, filter, csrfToken) } else if activeTab == "files" { @FilesTabFragment(tablo, files, csrfToken) } else { @@ -252,8 +252,11 @@ templ TabloOverviewTabFragment(tablo sqlc.Tablo, csrfToken string) { // TasksTabFragment wraps the KanbanBoard for use as a standalone HTMX tab fragment. // Returned by TabloTasksTabHandler on HX-Request == "true". // Lives in tablos.templ (tablo-level concern) per plan D-07. -templ TasksTabFragment(tablo sqlc.Tablo, tasks []sqlc.Task, csrfToken string) { - @KanbanBoard(tablo.ID, csrfToken, tasks) +templ TasksTabFragment(tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, csrfToken string) { +
+ @EtapeStrip(tablo.ID, etapes, counts, filter, csrfToken) + @KanbanBoard(tablo.ID, csrfToken, tasks, filter) +
} // TabloTitleDisplay renders the tablo title as a clickable element that swaps diff --git a/backend/templates/tasks.templ b/backend/templates/tasks.templ index 59cc3ff..8ddb10f 100644 --- a/backend/templates/tasks.templ +++ b/backend/templates/tasks.templ @@ -20,7 +20,7 @@ func groupTasksByStatus(tasks []sqlc.Task) map[sqlc.TaskStatus][]sqlc.Task { // KanbanBoard renders the outer board container with 4 columns and a hidden // reorder form. Used by TabloDetailPage below the tablo header section. // UI-SPEC §1 and D-08. -templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task) { +templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter EtapeFilter) { {{ grouped := groupTasksByStatus(tasks) }}
for _, status := range TaskColumns { - @KanbanColumn(tabloID, status, grouped[status], csrfToken) + @KanbanColumn(tabloID, status, grouped[status], csrfToken, filter) }