feat(09-01): add etape task slice

This commit is contained in:
Arthur Belleville 2026-05-15 22:40:25 +02:00
parent a8a3e5f596
commit 565bb88df5
No known key found for this signature in database
21 changed files with 587 additions and 64 deletions

View file

@ -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)

View file

@ -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;

View file

@ -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;

View file

@ -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())
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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())
}

View file

@ -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)
}

View file

@ -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))

View file

@ -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;

View file

@ -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) {
<div id="etape-strip" class="mb-4 space-y-3">
<div class="flex items-center gap-2 overflow-x-auto pb-1">
<a
href={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks") }
hx-get={ "/tablos/" + tabloID.String() + "/tasks" }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ "/tablos/" + tabloID.String() + "/tasks" }
class={ etapeChipClasses(filter.IsAll()) }
>
<span>All</span>
<span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">{ strconv.Itoa(counts.All) }</span>
</a>
<a
href={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks?etape=unassigned") }
hx-get={ "/tablos/" + tabloID.String() + "/tasks?etape=unassigned" }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ "/tablos/" + tabloID.String() + "/tasks?etape=unassigned" }
class={ etapeChipClasses(filter.IsUnassigned()) }
>
<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>
</a>
for _, etape := range etapes {
<a
href={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks?etape=" + etape.ID.String()) }
hx-get={ "/tablos/" + tabloID.String() + "/tasks?etape=" + etape.ID.String() }
hx-target="#tab-content"
hx-swap="innerHTML"
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>
</a>
}
<button
type="button"
class="ui-button ui-button-soft-neutral-md flex-shrink-0"
hx-get={ "/tablos/" + tabloID.String() + "/etapes/new" }
hx-target="#etape-form-slot"
hx-swap="innerHTML"
>+ Etape</button>
</div>
<div id="etape-form-slot"></div>
</div>
}
templ EtapeCreateFormFragment(tabloID uuid.UUID, form EtapeCreateForm, errs EtapeCreateErrors, csrfToken string) {
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/etapes") }
hx-post={ "/tablos/" + tabloID.String() + "/etapes" }
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>
}

View file

@ -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"
}

View file

@ -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) {
<div class="mb-4">
<a href="/" class="text-sm text-slate-600 hover:underline">&larr; Back to tablos</a>
@ -227,7 +227,7 @@ templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks
<!-- Tab content area — HTMX tab switches target this div -->
<div id="tab-content" class="mt-6">
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) {
<div id="tasks-tab">
@EtapeStrip(tablo.ID, etapes, counts, filter, csrfToken)
@KanbanBoard(tablo.ID, csrfToken, tasks, filter)
</div>
}
// TabloTitleDisplay renders the tablo title as a clickable element that swaps

View file

@ -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) }}
<div id="kanban-board" class="flex gap-4 overflow-x-auto pb-4">
<form
@ -35,7 +35,7 @@ templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task) {
@ui.CSRFField(csrfToken)
</form>
for _, status := range TaskColumns {
@KanbanColumn(tabloID, status, grouped[status], csrfToken)
@KanbanColumn(tabloID, status, grouped[status], csrfToken, filter)
}
<script>
(function() {
@ -103,7 +103,7 @@ templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task) {
// KanbanColumn renders a single kanban column: header, task list, and add-task slot.
// UI-SPEC §1 and §2.
templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string) {
templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string, filter EtapeFilter) {
<div class="flex-shrink-0 w-72">
<div class="bg-slate-100 rounded px-3 py-2 mb-2 flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-700">{ TaskColumnLabels[status] }</h3>
@ -125,7 +125,7 @@ templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task,
}
</div>
<div id={ "add-task-slot-" + string(status) }>
@AddTaskTrigger(tabloID, status, csrfToken)
@AddTaskTrigger(tabloID, status, csrfToken, filter)
</div>
</div>
}
@ -232,7 +232,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) {
templ TaskCreateFormFragment(tabloID uuid.UUID, status sqlc.TaskStatus, form TaskCreateForm, errs TaskCreateErrors, csrfToken string, filter EtapeFilter) {
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks") }
@ -242,6 +242,7 @@ templ TaskCreateFormFragment(tabloID uuid.UUID, status sqlc.TaskStatus, form Tas
class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2"
>
<input type="hidden" name="status" value={ string(status) }/>
<input type="hidden" name="etape_id" value={ form.EtapeID }/>
@ui.CSRFField(csrfToken)
<div>
<input
@ -270,7 +271,7 @@ templ TaskCreateFormFragment(tabloID uuid.UUID, status sqlc.TaskStatus, form Tas
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tabloID.String() + "/tasks/cancel-new?status=" + string(status),
"hx-get": "/tablos/" + tabloID.String() + "/tasks/cancel-new?status=" + string(status) + filter.QuerySuffix(),
"hx-target": "#add-task-slot-" + string(status),
"hx-swap": "innerHTML",
},
@ -328,11 +329,11 @@ templ TaskDeleteConfirmFragment(tabloID uuid.UUID, task sqlc.Task, csrfToken str
// AddTaskTrigger renders the "+ Add task" button that expands to TaskCreateFormFragment.
// Targets #add-task-slot-{status} for innerHTML replacement.
// UI-SPEC §2.
templ AddTaskTrigger(tabloID uuid.UUID, status sqlc.TaskStatus, csrfToken string) {
templ AddTaskTrigger(tabloID uuid.UUID, status sqlc.TaskStatus, csrfToken string, filter EtapeFilter) {
<button
type="button"
class="ui-button ui-button-soft-neutral-md w-full text-left text-sm mt-2"
hx-get={ "/tablos/" + tabloID.String() + "/tasks/new?status=" + string(status) }
hx-get={ "/tablos/" + tabloID.String() + "/tasks/new?status=" + string(status) + filter.QuerySuffix() }
hx-target={ "#add-task-slot-" + string(status) }
hx-swap="innerHTML"
>+ Add task</button>
@ -348,9 +349,9 @@ templ TaskCardGone(taskID uuid.UUID) {
// slot to AddTaskTrigger. Used by TaskCreateHandler to perform both operations
// in a single HTMX response.
// D-08/UI-SPEC §2: OOB swap resets #add-task-slot-{status} after create.
templ TaskCardOOB(status sqlc.TaskStatus, task sqlc.Task, tabloID uuid.UUID, csrfToken string) {
templ TaskCardOOB(status sqlc.TaskStatus, task sqlc.Task, tabloID uuid.UUID, csrfToken string, filter EtapeFilter) {
@TaskCard(tabloID, task, csrfToken)
<div hx-swap-oob={ "innerHTML:#add-task-slot-" + string(status) }>
@AddTaskTrigger(tabloID, status, csrfToken)
@AddTaskTrigger(tabloID, status, csrfToken, filter)
</div>
}

View file

@ -21,8 +21,9 @@ var TaskColumnLabels = map[sqlc.TaskStatus]string{
// TaskCreateForm carries submitted field values back to the template for
// repopulation on validation failure.
type TaskCreateForm struct {
Title string
Status string // holds the column status value, e.g. "todo"
Title string
Status string // holds the column status value, e.g. "todo"
EtapeID string
}
// TaskCreateErrors holds per-field and general error messages for the task