feat(10-02): add event edit and delete flows

This commit is contained in:
Arthur Belleville 2026-05-16 00:32:55 +02:00
parent e5f083d2a8
commit 614003f165
No known key found for this signature in database
5 changed files with 271 additions and 1 deletions

View file

@ -8,6 +8,22 @@ SELECT id, tablo_id, title, event_date, start_time, end_time, description, locat
FROM events
WHERE id = $1 AND tablo_id = $2;
-- name: UpdateEvent :one
UPDATE events
SET title = $3,
event_date = $4,
start_time = $5,
end_time = $6,
description = $7,
location = $8,
updated_at = now()
WHERE id = $1 AND tablo_id = $2
RETURNING id, tablo_id, title, event_date, start_time, end_time, description, location, created_at, updated_at;
-- name: DeleteEvent :exec
DELETE FROM events
WHERE id = $1 AND tablo_id = $2;
-- name: ListEventsByTabloRange :many
SELECT id, tablo_id, title, event_date, start_time, end_time, description, location, created_at, updated_at
FROM events

View file

@ -1,6 +1,7 @@
package web
import (
"errors"
"log/slog"
"net/http"
"strings"
@ -10,7 +11,9 @@ import (
"backend/templates"
"github.com/google/uuid"
"github.com/go-chi/chi/v5"
"github.com/gorilla/csrf"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
@ -107,6 +110,32 @@ func loadEventsCalendar(w http.ResponseWriter, r *http.Request, q *sqlc.Queries,
return buildEventsCalendar(month, events), true
}
func loadOwnedEvent(w http.ResponseWriter, r *http.Request, deps EventsDeps) (sqlc.Tablo, sqlc.Event, bool) {
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
if !ok {
return sqlc.Tablo{}, sqlc.Event{}, false
}
eventID, err := uuid.Parse(chi.URLParam(r, "event_id"))
if err != nil {
http.NotFound(w, r)
return sqlc.Tablo{}, sqlc.Event{}, false
}
event, err := deps.Queries.GetEventByID(r.Context(), sqlc.GetEventByIDParams{
ID: eventID,
TabloID: tablo.ID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return sqlc.Tablo{}, sqlc.Event{}, false
}
slog.Default().Error("events: GetEventByID failed", "tablo_id", tablo.ID, "event_id", eventID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return sqlc.Tablo{}, sqlc.Event{}, false
}
return tablo, event, true
}
func renderEventsTab(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) {
calendar, ok := loadEventsCalendar(w, r, q, tablo)
if !ok {
@ -189,6 +218,84 @@ func EventCreateHandler(deps EventsDeps) http.HandlerFunc {
}
}
func EventEditFormHandler(deps EventsDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, event, ok := loadOwnedEvent(w, r, deps)
if !ok {
return
}
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventEditFormFragment(tablo.ID, event, templates.EventFormFromEvent(event), templates.EventCreateErrors{}, csrf.Token(r), month).Render(r.Context(), w)
}
}
func EventUpdateHandler(deps EventsDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, event, ok := loadOwnedEvent(w, r, deps)
if !ok {
return
}
form, createParams, errs, valid := parseEventCreateForm(r, tablo.ID)
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
if !valid {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Retarget", "#event-form-slot")
w.Header().Set("HX-Reswap", "innerHTML")
}
w.WriteHeader(http.StatusUnprocessableEntity)
_ = templates.EventEditFormFragment(tablo.ID, event, form, errs, csrf.Token(r), month).Render(r.Context(), w)
return
}
if _, err := deps.Queries.UpdateEvent(r.Context(), sqlc.UpdateEventParams{
ID: event.ID,
TabloID: tablo.ID,
Title: createParams.Title,
EventDate: createParams.EventDate,
StartTime: createParams.StartTime,
EndTime: createParams.EndTime,
Description: createParams.Description,
Location: createParams.Location,
}); err != nil {
slog.Default().Error("events update: UpdateEvent failed", "tablo_id", tablo.ID, "event_id", event.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
renderEventsTab(w, r, deps.Queries, tablo)
}
}
func EventDeleteConfirmHandler(deps EventsDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, event, ok := loadOwnedEvent(w, r, deps)
if !ok {
return
}
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventDeleteConfirmFragment(tablo.ID, event, csrf.Token(r), month).Render(r.Context(), w)
}
}
func EventDeleteHandler(deps EventsDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, event, ok := loadOwnedEvent(w, r, deps)
if !ok {
return
}
if err := deps.Queries.DeleteEvent(r.Context(), sqlc.DeleteEventParams{
ID: event.ID,
TabloID: tablo.ID,
}); err != nil {
slog.Default().Error("events delete: DeleteEvent failed", "tablo_id", tablo.ID, "event_id", event.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
renderEventsTab(w, r, deps.Queries, tablo)
}
}
func parseEventCreateForm(r *http.Request, tabloID uuid.UUID) (templates.EventCreateForm, sqlc.CreateEventParams, templates.EventCreateErrors, bool) {
title := strings.TrimSpace(r.PostFormValue("title"))
eventDate := strings.TrimSpace(r.PostFormValue("event_date"))

View file

@ -119,6 +119,10 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep
r.Get("/tablos/{id}/events/new", EventNewFormHandler(eventDeps))
r.Get("/tablos/{id}/events/cancel-new", EventCancelNewHandler(eventDeps))
r.Post("/tablos/{id}/events", EventCreateHandler(eventDeps))
r.Get("/tablos/{id}/events/{event_id}/edit", EventEditFormHandler(eventDeps))
r.Post("/tablos/{id}/events/{event_id}", EventUpdateHandler(eventDeps))
r.Get("/tablos/{id}/events/{event_id}/delete-confirm", EventDeleteConfirmHandler(eventDeps))
r.Post("/tablos/{id}/events/{event_id}/delete", EventDeleteHandler(eventDeps))
// 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))

View file

@ -87,7 +87,7 @@ templ EventDayCell(tabloID uuid.UUID, month string, day EventCalendarDay) {
<button
type="button"
class="block w-full truncate rounded border border-slate-200 bg-slate-50 px-2 py-1 text-left text-sm text-slate-800 hover:border-slate-400"
hx-get={ "/tablos/" + tabloID.String() + "/events/" + event.ID.String() + "/edit?month=" + month }
hx-get={ EventEditURL(tabloID, event.ID, month) }
hx-target="#event-form-slot"
hx-swap="innerHTML"
aria-label={ "Edit event: " + event.Title }
@ -165,3 +165,118 @@ templ EventCreateFormFragment(tabloID uuid.UUID, form EventCreateForm, errs Even
</div>
</form>
}
templ EventEditFormFragment(tabloID uuid.UUID, event sqlc.Event, form EventCreateForm, errs EventCreateErrors, csrfToken string, month string) {
<form
method="POST"
action={ templ.SafeURL(EventUpdateURL(tabloID, event.ID, month)) }
hx-post={ EventUpdateURL(tabloID, event.ID, month) }
hx-target="#events-tab"
hx-swap="outerHTML"
class="rounded border border-slate-200 bg-white p-3 shadow-sm space-y-3"
>
@ui.CSRFField(csrfToken)
@GeneralError(errs.General)
<div>
<label for="event-edit-title" class="block text-sm font-medium text-slate-700">Title</label>
<input id="event-edit-title" type="text" name="title" value={ form.Title } maxlength="255" 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="grid gap-3 sm:grid-cols-3">
<div>
<label for="event-edit-date" class="block text-sm font-medium text-slate-700">Date</label>
<input id="event-edit-date" type="date" name="event_date" value={ form.EventDate } required class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none"/>
@FieldError(errs.EventDate)
</div>
<div>
<label for="event-edit-start-time" class="block text-sm font-medium text-slate-700">Start time</label>
<input id="event-edit-start-time" type="time" name="start_time" value={ form.StartTime } required class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none"/>
@FieldError(errs.StartTime)
</div>
<div>
<label for="event-edit-end-time" class="block text-sm font-medium text-slate-700">End time <span class="text-slate-400">(optional)</span></label>
<input id="event-edit-end-time" type="time" name="end_time" value={ form.EndTime } class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none"/>
@FieldError(errs.EndTime)
</div>
</div>
<div>
<label for="event-edit-location" class="block text-sm font-medium text-slate-700">Location <span class="text-slate-400">(optional)</span></label>
<input id="event-edit-location" type="text" name="location" value={ form.Location } maxlength="255" 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"/>
</div>
<div>
<label for="event-edit-description" class="block text-sm font-medium text-slate-700">Description <span class="text-slate-400">(optional)</span></label>
<textarea id="event-edit-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">{ form.Description }</textarea>
</div>
<div class="flex flex-wrap items-center gap-2">
@ui.Button(ui.ButtonProps{
Label: "Save event changes",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
@ui.Button(ui.ButtonProps{
Label: "Delete event",
Variant: ui.ButtonVariantDanger,
Tone: ui.ButtonToneSoft,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": EventDeleteConfirmURL(tabloID, event.ID, month),
"hx-target": "#event-form-slot",
"hx-swap": "innerHTML",
"aria-label": "Delete event: " + event.Title,
},
})
@ui.Button(ui.ButtonProps{
Label: "Close form",
Variant: ui.ButtonVariantNeutral,
Tone: ui.ButtonToneSoft,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tabloID.String() + "/events/cancel-new",
"hx-target": "#event-form-slot",
"hx-swap": "innerHTML",
},
})
</div>
</form>
}
templ EventDeleteConfirmFragment(tabloID uuid.UUID, event sqlc.Event, csrfToken string, month string) {
<div class="rounded border border-slate-200 bg-white p-3 shadow-sm space-y-3">
<p class="text-sm font-semibold text-slate-800">Delete event?</p>
<p class="text-sm text-slate-600">This removes the event from this tablo. This cannot be undone.</p>
<div class="flex flex-wrap items-center gap-2">
<form
method="POST"
action={ templ.SafeURL(EventDeleteURL(tabloID, event.ID, month)) }
hx-post={ EventDeleteURL(tabloID, event.ID, month) }
hx-target="#events-tab"
hx-swap="outerHTML"
>
@ui.CSRFField(csrfToken)
@ui.Button(ui.ButtonProps{
Label: "Delete event",
Variant: ui.ButtonVariantDanger,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
</form>
@ui.Button(ui.ButtonProps{
Label: "Keep event",
Variant: ui.ButtonVariantNeutral,
Tone: ui.ButtonToneSoft,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tabloID.String() + "/events/cancel-new",
"hx-target": "#event-form-slot",
"hx-swap": "innerHTML",
},
})
</div>
</div>
}

View file

@ -130,6 +130,34 @@ func EventPostURL(tabloID uuid.UUID, month string) string {
return "/tablos/" + tabloID.String() + "/events?month=" + month
}
func EventEditURL(tabloID uuid.UUID, eventID uuid.UUID, month string) string {
if month == "" {
return "/tablos/" + tabloID.String() + "/events/" + eventID.String() + "/edit"
}
return "/tablos/" + tabloID.String() + "/events/" + eventID.String() + "/edit?month=" + month
}
func EventUpdateURL(tabloID uuid.UUID, eventID uuid.UUID, month string) string {
if month == "" {
return "/tablos/" + tabloID.String() + "/events/" + eventID.String()
}
return "/tablos/" + tabloID.String() + "/events/" + eventID.String() + "?month=" + month
}
func EventDeleteConfirmURL(tabloID uuid.UUID, eventID uuid.UUID, month string) string {
if month == "" {
return "/tablos/" + tabloID.String() + "/events/" + eventID.String() + "/delete-confirm"
}
return "/tablos/" + tabloID.String() + "/events/" + eventID.String() + "/delete-confirm?month=" + month
}
func EventDeleteURL(tabloID uuid.UUID, eventID uuid.UUID, month string) string {
if month == "" {
return "/tablos/" + tabloID.String() + "/events/" + eventID.String() + "/delete"
}
return "/tablos/" + tabloID.String() + "/events/" + eventID.String() + "/delete?month=" + month
}
func EventMonthURL(tabloID uuid.UUID, month string) string {
return "/tablos/" + tabloID.String() + "/events?month=" + month
}