- Implement TablosListHandler, TablosNewHandler, TablosCreateHandler in
handlers_tablos.go replacing the Plan 01 stub
- TablosCreateHandler: reads via r.PostFormValue, validates title (required,
<=255), inserts with pgtype.Text nullable params, sends HX-Retarget +
HX-Reswap on HTMX success, 303 redirect on non-HTMX success
- router.go: replace r.Get("/", IndexHandler()) with TablosListHandler;
add GET /tablos/new and POST /tablos (static before parametric — Pitfall 1)
- handlers.go: remove IndexHandler + unused auth/csrf imports
- index.templ: reduced to bare package declaration (dashboard moved to tablos.templ)
- index_templ.go: deleted (empty templ file generates broken import)
- TestTabloList, TestTabloList_Empty, TestTabloCreate, TestTabloCreate_Validation: PASS
- TestSignup, TestLogin, TestLogout, TestCSRF: still PASS (no regression)
153 lines
5.7 KiB
Go
153 lines
5.7 KiB
Go
package web
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"backend/internal/auth"
|
|
"backend/internal/db/sqlc"
|
|
"backend/templates"
|
|
|
|
"github.com/gorilla/csrf"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
// TablosDeps holds dependencies for all tablo handlers.
|
|
// Introduced in Plan 01 as a stub to allow handlers_tablos_test.go to compile.
|
|
// Plan 02 adds the actual handler implementations.
|
|
type TablosDeps struct {
|
|
Queries *sqlc.Queries
|
|
}
|
|
|
|
// TablosListHandler handles GET / for authenticated users.
|
|
// Fetches all tablos for the current user newest-first and renders TablosDashboard.
|
|
// Returns the empty-state via TablosEmptyState when the user has no tablos (TABLO-01).
|
|
func TablosListHandler(deps TablosDeps) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
_, user, _ := auth.Authed(r.Context())
|
|
|
|
tablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID)
|
|
if err != nil {
|
|
slog.Default().Error("tablos list: query failed", "user_id", user.ID, "err", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if tablos == nil {
|
|
tablos = []sqlc.Tablo{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w)
|
|
}
|
|
}
|
|
|
|
// TablosNewHandler handles GET /tablos/new.
|
|
// Returns the create form fragment for HTMX insertion into #create-form-slot.
|
|
// Works without HX-Request too (falls back to rendering the fragment as a full response).
|
|
func TablosNewHandler(deps TablosDeps) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.TabloCreateFormFragment(
|
|
templates.TabloCreateForm{},
|
|
templates.TabloCreateErrors{},
|
|
csrf.Token(r),
|
|
).Render(r.Context(), w)
|
|
}
|
|
}
|
|
|
|
// TablosCreateHandler handles POST /tablos.
|
|
// Validates, inserts the tablo, and either:
|
|
// - HTMX path: sets HX-Retarget + HX-Reswap and renders TabloCardWithOOBFormClear (200)
|
|
// - Non-HTMX path: 303 redirect to / (TABLO-06 degrade-gracefully)
|
|
//
|
|
// Security invariants:
|
|
// - Form values read via r.PostFormValue only (gorilla/csrf consumes r.Body — Pitfall 2)
|
|
// - User extracted from RequireAuth-gated context (T-03-02-01)
|
|
// - Title validates non-empty and <= 255 chars (T-03-02-04)
|
|
// - Description and Color inserted as pgtype.Text{Valid: s != ""} (T-03-02-03)
|
|
// - On validation error: 422 + fragment (HTMX) or 422 + full page (non-HTMX)
|
|
func TablosCreateHandler(deps TablosDeps) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
_, user, _ := auth.Authed(ctx)
|
|
|
|
// 1. Read form values — always r.PostFormValue, never r.Body (Pitfall 2).
|
|
title := strings.TrimSpace(r.PostFormValue("title"))
|
|
description := r.PostFormValue("description")
|
|
color := strings.TrimSpace(r.PostFormValue("color"))
|
|
|
|
var errs templates.TabloCreateErrors
|
|
|
|
// 2. Validate title (UI-SPEC Validation copy).
|
|
if title == "" {
|
|
errs.Title = "Title is required."
|
|
} else if len(title) > 255 {
|
|
errs.Title = "Title must be 255 characters or fewer."
|
|
}
|
|
|
|
if errs.Title != "" {
|
|
renderTabloCreateError(w, r, templates.TabloCreateForm{
|
|
Title: title,
|
|
Description: description,
|
|
Color: color,
|
|
}, errs, http.StatusUnprocessableEntity, deps)
|
|
return
|
|
}
|
|
|
|
// 3. Build insert params with nullable pgtype.Text for description and color.
|
|
params := sqlc.InsertTabloParams{
|
|
UserID: user.ID,
|
|
Title: title,
|
|
Description: pgtype.Text{String: description, Valid: description != ""},
|
|
Color: pgtype.Text{String: color, Valid: color != ""},
|
|
}
|
|
|
|
tablo, err := deps.Queries.InsertTablo(ctx, params)
|
|
if err != nil {
|
|
slog.Default().Error("tablos create: insert failed", "user_id", user.ID, "err", err)
|
|
errs.General = "Something went wrong. Please try again."
|
|
renderTabloCreateError(w, r, templates.TabloCreateForm{
|
|
Title: title,
|
|
Description: description,
|
|
Color: color,
|
|
}, errs, http.StatusInternalServerError, deps)
|
|
return
|
|
}
|
|
|
|
// 4. Success response.
|
|
// HTMX: dual-target swap — prepend card to #tablos-list + OOB clear form slot.
|
|
// Non-HTMX: 303 redirect to / (Pitfall 9 — NOT 302).
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("HX-Retarget", "#tablos-list")
|
|
w.Header().Set("HX-Reswap", "afterbegin")
|
|
_ = templates.TabloCardWithOOBFormClear(tablo, csrf.Token(r)).Render(ctx, w)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// renderTabloCreateError writes a tablo create validation-error response.
|
|
// For HTMX requests it renders only the form fragment; for plain requests it
|
|
// renders the full dashboard (mirrors the auth handler pattern).
|
|
func renderTabloCreateError(w http.ResponseWriter, r *http.Request, form templates.TabloCreateForm, errs templates.TabloCreateErrors, status int, deps TablosDeps) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
_ = templates.TabloCreateFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w)
|
|
return
|
|
}
|
|
// Non-HTMX: render full dashboard with errs embedded in the form.
|
|
// Fetch the user's tablos so the list is still accurate on re-render.
|
|
_, user, _ := auth.Authed(r.Context())
|
|
tablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID)
|
|
if err != nil {
|
|
tablos = []sqlc.Tablo{}
|
|
}
|
|
// Render full page — form fragment is not embedded in the full page by default;
|
|
// for the non-HTMX error case we redirect so the user sees their list intact
|
|
// and can try again (simpler than threading form state through the full page).
|
|
_ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w)
|
|
}
|