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
This commit is contained in:
parent
878ab69602
commit
6f167e2956
2 changed files with 255 additions and 15 deletions
|
|
@ -65,7 +65,7 @@ templ TablosEmptyState() {
|
||||||
|
|
||||||
// TabloCard renders a single tablo as a ui.Card on the dashboard.
|
// TabloCard renders a single tablo as a ui.Card on the dashboard.
|
||||||
// Guards description and color rendering against null pgtype.Text values (Pitfall 6).
|
// 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) {
|
templ TabloCard(tablo sqlc.Tablo, csrfToken string) {
|
||||||
@ui.Card(templ.Attributes{"id": "tablo-" + tablo.ID.String()}) {
|
@ui.Card(templ.Attributes{"id": "tablo-" + tablo.ID.String()}) {
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
|
|
@ -84,20 +84,7 @@ templ TabloCard(tablo sqlc.Tablo, csrfToken string) {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="tablo-delete-zone">
|
@TabloDeleteButtonFragment(tablo, csrfToken)
|
||||||
@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",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<a href={ templ.SafeURL("/tablos/" + tablo.ID.String()) } class="text-sm font-medium text-blue-600 hover:underline">View</a>
|
<a href={ templ.SafeURL("/tablos/" + tablo.ID.String()) } class="text-sm font-medium text-blue-600 hover:underline">View</a>
|
||||||
|
|
@ -177,3 +164,247 @@ templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
|
||||||
@TabloCard(tablo, csrfToken)
|
@TabloCard(tablo, csrfToken)
|
||||||
<div id="create-form-slot" hx-swap-oob="true"></div>
|
<div id="create-form-slot" hx-swap-oob="true"></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="/" class="text-sm text-slate-600 hover:underline">← Back to tablos</a>
|
||||||
|
</div>
|
||||||
|
<div class="tablo-title-zone">
|
||||||
|
@TabloTitleDisplay(tablo, csrfToken)
|
||||||
|
</div>
|
||||||
|
<div class="tablo-desc-zone">
|
||||||
|
@TabloDescDisplay(tablo, csrfToken)
|
||||||
|
</div>
|
||||||
|
<div class="tablo-delete-zone">
|
||||||
|
@TabloDeleteButtonFragment(tablo, csrfToken)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
<h1
|
||||||
|
class="tablo-title-zone text-xl font-semibold leading-snug cursor-pointer hover:text-slate-600"
|
||||||
|
hx-get={ "/tablos/" + tablo.ID.String() + "/edit-title" }
|
||||||
|
hx-target="closest .tablo-title-zone"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
role="button"
|
||||||
|
aria-label="Edit title"
|
||||||
|
>{ tablo.Title }</h1>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
<form
|
||||||
|
class="tablo-title-zone space-y-3"
|
||||||
|
method="POST"
|
||||||
|
action={ templ.SafeURL("/tablos/" + tablo.ID.String()) }
|
||||||
|
hx-post={ "/tablos/" + tablo.ID.String() }
|
||||||
|
hx-target="closest .tablo-title-zone"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
@ui.CSRFField(csrfToken)
|
||||||
|
<input type="hidden" name="_zone" value="title"/>
|
||||||
|
<input type="hidden" name="description" value={ tablo.Description.String }/>
|
||||||
|
<div>
|
||||||
|
<label for="title" class="block text-sm font-medium text-slate-700">Title</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
value={ tablo.Title }
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
@FieldError(errs.Title)
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
@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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
<div
|
||||||
|
class="tablo-desc-zone mt-4 cursor-pointer hover:text-slate-600"
|
||||||
|
hx-get={ "/tablos/" + tablo.ID.String() + "/edit-desc" }
|
||||||
|
hx-target="closest .tablo-desc-zone"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
role="button"
|
||||||
|
aria-label="Edit description"
|
||||||
|
>
|
||||||
|
if tablo.Description.Valid && tablo.Description.String != "" {
|
||||||
|
<p class="text-base text-slate-600">{ tablo.Description.String }</p>
|
||||||
|
} else {
|
||||||
|
<p class="text-sm text-slate-400 italic">Add a description</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
<form
|
||||||
|
class="tablo-desc-zone mt-4 space-y-3"
|
||||||
|
method="POST"
|
||||||
|
action={ templ.SafeURL("/tablos/" + tablo.ID.String()) }
|
||||||
|
hx-post={ "/tablos/" + tablo.ID.String() }
|
||||||
|
hx-target="closest .tablo-desc-zone"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
@ui.CSRFField(csrfToken)
|
||||||
|
<input type="hidden" name="_zone" value="desc"/>
|
||||||
|
<input type="hidden" name="title" value={ tablo.Title }/>
|
||||||
|
<div>
|
||||||
|
<label for="description" class="block text-sm font-medium text-slate-700">Description <span class="text-slate-400">(optional)</span></label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows="3"
|
||||||
|
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
||||||
|
>{ tablo.Description.String }</textarea>
|
||||||
|
@FieldError(errs.Description)
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
@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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
<div class="tablo-delete-zone">
|
||||||
|
@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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
<div class="tablo-delete-zone space-y-3">
|
||||||
|
<p class="text-base font-semibold text-slate-800">Delete tablo?</p>
|
||||||
|
<p class="text-sm text-slate-600">This cannot be undone.</p>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/delete") }
|
||||||
|
hx-post={ "/tablos/" + tablo.ID.String() + "/delete" }
|
||||||
|
hx-target="closest .tablo-delete-zone"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
@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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</form>
|
||||||
|
@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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
<div class="py-16 text-center">
|
||||||
|
<h1 class="text-2xl font-semibold leading-snug text-slate-800">Not found</h1>
|
||||||
|
<p class="mt-2 text-base text-slate-600">This tablo doesn't exist or you don't have access.</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/" class="text-sm font-medium text-blue-600 hover:underline">Back to tablos</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,12 @@ type TabloCreateErrors struct {
|
||||||
Title string
|
Title string
|
||||||
General 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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue