Plans cover TABLO-01..06 via MVP vertical slices: foundation (migration + sqlc + test scaffold + button CSS), list+create (dashboard, inline form, OOB swap), and detail+edit+delete (ownership 404, inline edit fragments, inline confirm delete). Includes Nyquist VALIDATION.md and PATTERNS.md with real analog excerpts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | tags | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-tablos-crud | 02 | execute | 2 |
|
|
true |
|
|
|
Purpose: Deliver real user-visible value as the first slice — the dashboard renders, the create flow works, and the user can see a brand-new tablo appear without a full reload.
Output:
- handlers_tablos.go with TablosDeps + List/New/Create handlers
- tablos.templ with dashboard page, empty state, card, create form, OOB-clear template
- router.go updated: replace IndexHandler with TablosListHandler; mount /tablos/new and /tablos
- main.go constructs TablosDeps
- layout.templ footer "Phase 3 · Tablos"
- index.templ + IndexHandler removed (or emptied) — dashboard now lives in tablos.templ
<execution_context> @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/STATE.md @.planning/phases/03-tablos-crud/03-CONTEXT.md @.planning/phases/03-tablos-crud/03-RESEARCH.md @.planning/phases/03-tablos-crud/03-PATTERNS.md @.planning/phases/03-tablos-crud/03-UI-SPEC.md @.planning/phases/03-tablos-crud/03-01-SUMMARY.md @backend/internal/web/handlers_auth.go @backend/internal/web/router.go @backend/cmd/web/main.go @backend/templates/auth_signup.templ @backend/templates/index.templ @backend/templates/layout.templ @backend/internal/web/ui/button.templ @backend/internal/web/ui/card.templ @backend/internal/web/ui/variants.go @backend/internal/db/sqlc/tablos.sql.goTablosDeps (declared in backend/internal/web/handlers_tablos.go):
- Queries *sqlc.Queries
Handler constructors (signature pattern matches AuthDeps):
- TablosListHandler(deps TablosDeps) http.HandlerFunc → GET /
- TablosNewHandler(deps TablosDeps) http.HandlerFunc → GET /tablos/new (returns TabloCreateFormFragment)
- TablosCreateHandler(deps TablosDeps) http.HandlerFunc → POST /tablos
NewRouter new signature (replaces existing): func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler
Templates (callable by Plan 03):
- TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo) — full page
- TablosEmptyState() — empty list state
- TabloCard(t sqlc.Tablo, csrfToken string) — single card on dashboard; wraps in ui.Card with class "tablo-delete-zone" on delete button container
- TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrfToken string) — inline create form
- TabloCardWithOOBFormClear(t sqlc.Tablo, csrfToken string) — card + OOB div clearing #create-form-slot
Form types (declared in backend/templates/tablos.templ or a sibling tablos_forms.go file, mirroring auth_forms.go):
- TabloCreateForm { Title, Description, Color string }
- TabloCreateErrors { Title, General string }
Implement these templ components per the behavior block:
- TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo)
- TablosEmptyState()
- TabloCard(t sqlc.Tablo, csrfToken string) — include `<div class="tablo-delete-zone">` wrapper around the Delete button. The Delete button for now uses `hx-get="/tablos/{id}/delete-confirm"`, `hx-target="closest .tablo-delete-zone"`, `hx-swap="outerHTML"`, Variant ButtonVariantDanger, Tone ButtonToneSoft, Size SizeMD, Type "button". Plan 03 implements /delete-confirm.
- TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrfToken string)
- TabloCardWithOOBFormClear(t sqlc.Tablo, csrfToken string) — emit @TabloCard then a `<div id="create-form-slot" hx-swap-oob="true"></div>` as TOP-LEVEL siblings inside the template body (no wrapping container).
Imports in tablos.templ: "backend/internal/auth", "backend/internal/db/sqlc", "backend/internal/web/ui". Use `{ tablo.Title }`, `{ tablo.Description.String }`, etc. — templ auto-escapes (V5 XSS).
Modify backend/templates/layout.templ footer text from "Phase 2 · Authentication" → "Phase 3 · Tablos". No other layout edits.
Run `just generate` (or `templ generate`) to produce tablos_templ.go and the updated layout_templ.go. Pitfall 3.
cd backend && just generate && grep -q "templ TablosDashboard" templates/tablos.templ && grep -q "templ TablosEmptyState" templates/tablos.templ && grep -q "templ TabloCard" templates/tablos.templ && grep -q "templ TabloCreateFormFragment" templates/tablos.templ && grep -q "templ TabloCardWithOOBFormClear" templates/tablos.templ && grep -q "id=\"create-form-slot\" hx-swap-oob=\"true\"" templates/tablos.templ && grep -q "Phase 3 · Tablos" templates/layout.templ && grep -q "Create your first tablo to get started." templates/tablos.templ && grep -q "No tablos yet" templates/tablos.templ && grep -q "tablo-delete-zone" templates/tablos.templ && grep -q "type TabloCreateForm" templates/tablos_forms.go && go build ./...
- File backend/templates/tablos.templ exists with `package templates` and imports "backend/internal/auth", "backend/internal/db/sqlc", "backend/internal/web/ui".
- File contains the strings: `templ TablosDashboard`, `templ TablosEmptyState`, `templ TabloCard`, `templ TabloCreateFormFragment`, `templ TabloCardWithOOBFormClear`.
- File contains exact copy strings (UI-SPEC Copywriting Contract): "Your Tablos", "New tablo", "Create a tablo", "Create tablo", "No tablos yet", "Create your first tablo to get started.".
- File contains `id="create-form-slot"` and `id="tablos-list"`.
- TabloCardWithOOBFormClear emits `2) Implement TablosListHandler, TablosNewHandler, TablosCreateHandler per the behavior block. For nullable insert params use `pgtype.Text{String: s, Valid: s != ""}` (import "github.com/jackc/pgx/v5/pgtype"). For trim/validate, use `strings.TrimSpace`. For error responses use `w.WriteHeader(http.StatusUnprocessableEntity)` + form fragment (HX) or full dashboard (non-HX). For HTMX success use w.Header().Set("HX-Retarget", "#tablos-list") and w.Header().Set("HX-Reswap", "afterbegin"). Status 200 for HTMX success; 303 to "/" otherwise (http.StatusSeeOther — Pitfall 9 from Phase 2 carries over).
3) Edit backend/internal/web/router.go: change NewRouter signature to add `tabloDeps TablosDeps` as the parameter immediately after `deps AuthDeps`. Inside the existing protected group, replace `r.Get("/", IndexHandler())` with `r.Get("/", TablosListHandler(tabloDeps))` and add (in this order — static-before-parametric, Pitfall 1):
- r.Get("/tablos/new", TablosNewHandler(tabloDeps))
- r.Post("/tablos", TablosCreateHandler(tabloDeps))
Do NOT yet add /tablos/{id} routes — Plan 03 owns those.
4) Edit backend/cmd/web/main.go: after the existing `deps := web.AuthDeps{...}` line, add `tabloDeps := web.TablosDeps{Queries: q}` and pass it as the new arg to NewRouter (positional, between deps and csrfKey).
5) Remove backend/internal/web/handlers.go IndexHandler. Remove backend/templates/index.templ (delete or reduce to bare `package templates`). Delete backend/templates/index_templ.go if `just generate` does not regenerate it (templ should remove it on next run if the .templ is gone; if leftover, delete manually).
6) Run `just generate` then `just test`. TestTabloList, TestTabloList_Empty, TestTabloCreate, TestTabloCreate_Validation must turn green; tests for endpoints not yet implemented (Plan 03) remain red.
cd backend && just generate && grep -q "type TablosDeps struct" internal/web/handlers_tablos.go && grep -q "func TablosListHandler" internal/web/handlers_tablos.go && grep -q "func TablosNewHandler" internal/web/handlers_tablos.go && grep -q "func TablosCreateHandler" internal/web/handlers_tablos.go && grep -q "HX-Retarget" internal/web/handlers_tablos.go && grep -q "HX-Reswap" internal/web/handlers_tablos.go && grep -q "pgtype.Text" internal/web/handlers_tablos.go && grep -q "r.PostFormValue(\"title\")" internal/web/handlers_tablos.go && grep -q "tabloDeps TablosDeps" internal/web/router.go && grep -q "r.Get(\"/tablos/new\"" internal/web/router.go && grep -q "r.Post(\"/tablos\"" internal/web/router.go && grep -q "TablosDeps{Queries: q}" cmd/web/main.go && ! grep -q "IndexHandler" internal/web/handlers.go && go build ./... && go test ./internal/web/... -run "TestTabloList|TestTabloList_Empty|TestTabloCreate|TestTabloCreate_Validation" -count=1
- backend/internal/web/handlers_tablos.go declares: `type TablosDeps struct { Queries *sqlc.Queries }`, `func TablosListHandler(deps TablosDeps) http.HandlerFunc`, `func TablosNewHandler(deps TablosDeps) http.HandlerFunc`, `func TablosCreateHandler(deps TablosDeps) http.HandlerFunc`.
- handlers_tablos.go uses `r.PostFormValue("title")`, `r.PostFormValue("description")`, `r.PostFormValue("color")` (no r.Body reads).
- handlers_tablos.go contains literal "Title is required." and "Title must be 255 characters or fewer." strings.
- handlers_tablos.go sets HX-Retarget and HX-Reswap headers in the create-success path.
- handlers_tablos.go uses pgtype.Text{...Valid: s != ""...} for nullable Description and Color inserts.
- router.go signature reads `func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler`.
- router.go protected group contains `r.Get("/", TablosListHandler(tabloDeps))` (no IndexHandler reference), `r.Get("/tablos/new", TablosNewHandler(tabloDeps))` (declared BEFORE any parametric /tablos/{id} route which is added in Plan 03), `r.Post("/tablos", TablosCreateHandler(tabloDeps))`.
- cmd/web/main.go contains `tabloDeps := web.TablosDeps{Queries: q}` and passes it to NewRouter.
- backend/internal/web/handlers.go no longer contains the symbol `IndexHandler` (`grep -c IndexHandler handlers.go` == 0).
- `go build ./...` exits 0.
- `go test ./internal/web/... -run "TestTabloList|TestTabloList_Empty|TestTabloCreate|TestTabloCreate_Validation"` exits 0 (all four pass).
- Phase 1/2 tests still pass: `go test ./internal/web/... -run "TestSignup|TestLogin|TestLogout|TestCSRF"` exits 0.
Dashboard + create slice green; create-form-slot OOB clear verified; non-HTMX 303 verified.
Task 3: Human verify list + create flow
Dashboard at GET / showing user's tablos newest-first or empty state. "New tablo" button expands inline form via HTMX; submitting creates a row, clears the form slot, and prepends the new card without a full reload. Non-JS fallback works via plain form POST.
1. cd backend && just dev (web server on local port).
2. Open http://localhost:PORT/login, log in with an existing test account (or sign up).
3. On the dashboard, confirm heading "Your Tablos" and either tablo cards or empty-state text "No tablos yet" + "Create your first tablo to get started.".
4. Click "New tablo" — inline form appears above the list, no full page reload (check DevTools Network panel: only XHR to /tablos/new).
5. Submit form with title "My first tablo" + description "Hello" + color "#6366f1": form collapses, new card appears at top of list, no full reload.
6. Submit form with empty title: form stays open, "Title is required." appears under the field.
7. Open a private window with no JS — submit the form: page does a real POST and redirects to /; new tablo appears in list.
8. View page source: verify CSRF token is rendered in the form's hidden _csrf field.
Type "approved" if the dashboard + create flow works as described, or describe issues.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Browser → POST /tablos | Untrusted form input (title, description, color) crosses into the handler. |
| Browser → GET /tablos/new | Authed GET; no body input. |
| Handler → Postgres | Parameterised via sqlc-generated InsertTabloParams. |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-03-02-01 | Spoofing | session cookie | mitigate (inherited) | RequireAuth middleware from Phase 2 wraps the entire tablos group — unauthed requests redirect to /login. |
| T-03-02-02 | Tampering | CSRF on POST /tablos | mitigate | gorilla/csrf middleware from Phase 2 validates _csrf field; @ui.CSRFField(csrfToken) embedded in TabloCreateFormFragment. Missing token → 403 before handler runs. |
| T-03-02-03 | Tampering | r.Body drained by gorilla/csrf | mitigate | Use r.PostFormValue exclusively (Pitfall 2). |
| T-03-02-04 | DoS | over-long title | mitigate | TablosCreateHandler validates len(strings.TrimSpace(title)) > 0 AND <= 255; >255 returns 422 before DB insert. |
| T-03-02-05 | XSS via title/description/color | Tampering | mitigate | templ auto-escapes all { variable } interpolations; color rendered into inline style attribute is templ-escaped (inline style accepts limited chars, but templ's escaping prevents attribute breakout). No templ.Raw anywhere. |
| T-03-02-06 | Tampering via OOB swap | hx-swap-oob top-level requirement | mitigate | TabloCardWithOOBFormClear emits the OOB div as a top-level sibling (Pitfall 5); template structure asserted in Task 1 acceptance criteria. |
| T-03-02-07 | Elevation of privilege | Listing other users' tablos | mitigate | ListTablosByUser query is scoped by $1 = user.ID from auth.Authed(ctx); no path parameters, no way to widen scope. |
| </threat_model> |
<success_criteria>
- Dashboard at GET / renders user's tablos newest-first (TABLO-01).
- Empty state appears when user has zero tablos with exact UI-SPEC copy.
- Creating a tablo via HTMX prepends a new card and clears the form slot in one round trip (TABLO-02 + TABLO-06).
- Empty-title validation surfaces "Title is required." inline.
- Non-HTMX POST /tablos redirects 303 to / (TABLO-06 degrade-gracefully).
- CSRF token present in the create form (AUTH-06 inherited).
- Plan 03 has the wiring it needs: TablosDeps available, NewRouter signature stable, TabloCard renders a
.tablo-delete-zoneplaceholder. </success_criteria>