From 6f167e29565e0be381f6063524a7a8776d8ffc58 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 07:59:10 +0200 Subject: [PATCH] feat(03-03): detail page, edit and delete templ fragments + TabloUpdateErrors - TabloDetailPage: full detail layout with title/desc/delete zones - TabloTitleDisplay/EditFragment: outerHTML-swappable title zone with _zone=title hidden field - TabloDescDisplay/EditFragment: outerHTML-swappable desc zone with _zone=desc hidden field - TabloDeleteButtonFragment: canonical single-source delete zone (TabloCard now delegates here) - TabloDeleteConfirmFragment: inline confirm with "Delete tablo?", "Yes, delete", "Keep tablo" - TabloNotFoundPage: 404 page with UI-SPEC copy - TabloUpdateErrors struct added to tablos_forms.go - just generate + go build ./... both exit 0 --- backend/templates/tablos.templ | 261 ++++++++++++++++++++++++++++-- backend/templates/tablos_forms.go | 9 ++ 2 files changed, 255 insertions(+), 15 deletions(-) diff --git a/backend/templates/tablos.templ b/backend/templates/tablos.templ index 491bf23..1a2fba5 100644 --- a/backend/templates/tablos.templ +++ b/backend/templates/tablos.templ @@ -65,7 +65,7 @@ templ TablosEmptyState() { // TabloCard renders a single tablo as a ui.Card on the dashboard. // Guards description and color rendering against null pgtype.Text values (Pitfall 6). -// Wraps the Delete button in a .tablo-delete-zone div for Plan 03's delete-confirm swap. +// Delegates delete-zone rendering to TabloDeleteButtonFragment (single source of truth). templ TabloCard(tablo sqlc.Tablo, csrfToken string) { @ui.Card(templ.Attributes{"id": "tablo-" + tablo.ID.String()}) {
@@ -84,20 +84,7 @@ templ TabloCard(tablo sqlc.Tablo, csrfToken string) {
} -
- @ui.Button(ui.ButtonProps{ - Label: "Delete", - Variant: ui.ButtonVariantDanger, - Tone: ui.ButtonToneSoft, - Size: ui.SizeMD, - Type: "button", - Attrs: templ.Attributes{ - "hx-get": "/tablos/" + tablo.ID.String() + "/delete-confirm", - "hx-target": "closest .tablo-delete-zone", - "hx-swap": "outerHTML", - }, - }) -
+ @TabloDeleteButtonFragment(tablo, csrfToken)
View @@ -177,3 +164,247 @@ templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) { @TabloCard(tablo, csrfToken)
} + +// TabloDetailPage renders the full detail page for a single tablo. +// Includes title zone, description zone, and delete zone. +// UI-SPEC §3 Interaction Contract — GET /tablos/{id}. +templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo) { + @Layout("Tablos — Xtablo", user, csrfToken) { + +
+ @TabloTitleDisplay(tablo, csrfToken) +
+
+ @TabloDescDisplay(tablo, csrfToken) +
+
+ @TabloDeleteButtonFragment(tablo, csrfToken) +
+ } +} + +// TabloTitleDisplay renders the tablo title as a clickable element that swaps +// to the edit form on click. The outermost element carries class tablo-title-zone +// so hx-target="closest .tablo-title-zone" + hx-swap="outerHTML" round-trips cleanly. +// UI-SPEC §4 Interaction Contract — title inline-edit display state. +templ TabloTitleDisplay(tablo sqlc.Tablo, csrfToken string) { +

{ tablo.Title }

+} + +// TabloTitleEditFragment renders the inline title edit form. Carries class +// tablo-title-zone on the form element so outerHTML round-trips work correctly. +// Includes hidden description field (current value) + hidden _zone="title" for +// TabloUpdateHandler to know which display fragment to render on success. +// UI-SPEC §4 Interaction Contract — title inline-edit edit state. +templ TabloTitleEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string) { +
+ @ui.CSRFField(csrfToken) + + +
+ + + @FieldError(errs.Title) +
+
+ @ui.Button(ui.ButtonProps{ + Label: "Save changes", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "submit", + }) + @ui.Button(ui.ButtonProps{ + Label: "Discard changes", + Variant: ui.ButtonVariantNeutral, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": "/tablos/" + tablo.ID.String() + "/show-title", + "hx-target": "closest .tablo-title-zone", + "hx-swap": "outerHTML", + }, + }) +
+
+} + +// TabloDescDisplay renders the tablo description as a clickable element that +// swaps to the edit form on click. Carries class tablo-desc-zone on the outermost +// element for clean outerHTML round-trips. +// UI-SPEC §4 Interaction Contract — description inline-edit display state. +templ TabloDescDisplay(tablo sqlc.Tablo, csrfToken string) { +
+ if tablo.Description.Valid && tablo.Description.String != "" { +

{ tablo.Description.String }

+ } else { +

Add a description

+ } +
+} + +// TabloDescEditFragment renders the inline description edit form. Carries class +// tablo-desc-zone on the form element so outerHTML round-trips work correctly. +// Includes hidden title field (current value) + hidden _zone="desc" for +// TabloUpdateHandler to know which display fragment to render on success. +// UI-SPEC §4 Interaction Contract — description inline-edit edit state. +templ TabloDescEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string) { +
+ @ui.CSRFField(csrfToken) + + +
+ + + @FieldError(errs.Description) +
+
+ @ui.Button(ui.ButtonProps{ + Label: "Save changes", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "submit", + }) + @ui.Button(ui.ButtonProps{ + Label: "Discard changes", + Variant: ui.ButtonVariantNeutral, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": "/tablos/" + tablo.ID.String() + "/show-desc", + "hx-target": "closest .tablo-desc-zone", + "hx-swap": "outerHTML", + }, + }) +
+
+} + +// TabloDeleteButtonFragment renders the delete trigger button wrapped in a +// .tablo-delete-zone div. This is the canonical single source of truth for the +// delete zone shape — TabloCard delegates to this component (no duplication). +// UI-SPEC §5 Interaction Contract — delete display state. +templ TabloDeleteButtonFragment(tablo sqlc.Tablo, csrfToken string) { +
+ @ui.Button(ui.ButtonProps{ + Label: "Delete", + Variant: ui.ButtonVariantDanger, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": "/tablos/" + tablo.ID.String() + "/delete-confirm", + "hx-target": "closest .tablo-delete-zone", + "hx-swap": "outerHTML", + "aria-label": "Delete tablo", + }, + }) +
+} + +// TabloDeleteConfirmFragment renders the inline confirmation dialog that appears +// when the user clicks Delete. Carries class tablo-delete-zone for outerHTML +// round-trips. "Yes, delete" submits the DELETE action; "Keep tablo" restores +// the original Delete button via TabloDeleteCancelHandler. +// UI-SPEC §5 Interaction Contract — delete confirmation state. +templ TabloDeleteConfirmFragment(tablo sqlc.Tablo, csrfToken string) { +
+

Delete tablo?

+

This cannot be undone.

+
+
+ @ui.CSRFField(csrfToken) + @ui.Button(ui.ButtonProps{ + Label: "Yes, delete", + Variant: ui.ButtonVariantDanger, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "submit", + Attrs: templ.Attributes{ + "aria-label": "Confirm delete tablo", + }, + }) +
+ @ui.Button(ui.ButtonProps{ + Label: "Keep tablo", + Variant: ui.ButtonVariantNeutral, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": "/tablos/" + tablo.ID.String() + "/delete-cancel", + "hx-target": "closest .tablo-delete-zone", + "hx-swap": "outerHTML", + "aria-label": "Keep tablo", + }, + }) +
+
+} + +// TabloNotFoundPage renders a 404 page for tablos that don't exist or are not +// accessible by the current user (D-04: 404 not 403 to avoid existence leakage). +// user may be nil when called from an unauthenticated context — Layout handles nil. +// UI-SPEC Copywriting Contract: "Not found" + "This tablo doesn't exist or you don't have access." +templ TabloNotFoundPage(user *auth.User, csrfToken string) { + @Layout("Not found", user, csrfToken) { +
+

Not found

+

This tablo doesn't exist or you don't have access.

+ +
+ } +} diff --git a/backend/templates/tablos_forms.go b/backend/templates/tablos_forms.go index 80ab8b5..e3a4d3f 100644 --- a/backend/templates/tablos_forms.go +++ b/backend/templates/tablos_forms.go @@ -14,3 +14,12 @@ type TabloCreateErrors struct { Title string General string } + +// TabloUpdateErrors holds per-field and general error messages for the tablo +// inline-edit forms (title and description). A field with an empty string +// means "no error for this field". +type TabloUpdateErrors struct { + Title string + Description string + General string +}