feat(10-02): add event edit and delete flows
This commit is contained in:
parent
e5f083d2a8
commit
614003f165
5 changed files with 271 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue