feat(09-01): add etape task slice
This commit is contained in:
parent
a8a3e5f596
commit
565bb88df5
21 changed files with 587 additions and 64 deletions
|
|
@ -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)
|
||||
|
|
|
|||
35
backend/internal/db/queries/etapes.sql
Normal file
35
backend/internal/db/queries/etapes.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
200
backend/internal/web/handlers_etapes.go
Normal file
200
backend/internal/web/handlers_etapes.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
27
backend/migrations/0007_etapes.sql
Normal file
27
backend/migrations/0007_etapes.sql
Normal 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;
|
||||
116
backend/templates/etapes.templ
Normal file
116
backend/templates/etapes.templ
Normal 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>
|
||||
}
|
||||
88
backend/templates/etapes_forms.go
Normal file
88
backend/templates/etapes_forms.go
Normal 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"
|
||||
}
|
||||
|
|
@ -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">← 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue