From 889164b4371ccfdea422b7723582a0bc6e1a8b88 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 09:32:06 +0200 Subject: [PATCH] feat(04-02): KanbanBoard, TaskCard, TaskDeleteConfirmFragment templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tasks.templ with KanbanBoard, KanbanColumn, TaskCard, TaskCreateFormFragment, TaskDeleteConfirmFragment, AddTaskTrigger, TaskCardOOB - Add TaskColumns/TaskColumnLabels to tasks_forms.go (moved from web package to break import cycle) - Update TabloDetailPage signature to accept tasks []sqlc.Task; embed KanbanBoard below tablo header - Update handlers_tablos.go TabloDetailHandler to fetch tasks via ListTasksByTablo - Update layout.templ: add sortable.min.js script tag, update footer to Phase 4 · Tasks --- backend/internal/web/handlers_tablos.go | 24 ++- backend/templates/layout.templ | 3 +- backend/templates/tablos.templ | 8 +- backend/templates/tasks.templ | 224 ++++++++++++++++++++++++ backend/templates/tasks_forms.go | 18 ++ 5 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 backend/templates/tasks.templ diff --git a/backend/internal/web/handlers_tablos.go b/backend/internal/web/handlers_tablos.go index 9518dcf..86799b0 100644 --- a/backend/internal/web/handlers_tablos.go +++ b/backend/internal/web/handlers_tablos.go @@ -184,14 +184,25 @@ func loadOwnedTablo(w http.ResponseWriter, r *http.Request, deps TablosDeps) (sq // TabloDetailHandler handles GET /tablos/{id}. // Renders the tablo detail page for the authenticated owner; 404 for non-owner // and invalid UUIDs (TABLO-03, T-03-03-01, T-03-03-03). +// Also fetches the tablo's tasks for the kanban board (Plan 02). func TabloDetailHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, user, ok := loadOwnedTablo(w, r, deps) if !ok { return } + // Fetch tasks for the kanban board. On error, log and use empty slice — + // the tablo itself is valid so we still render the page (Plan 02, T-04-07). + tasks, err := deps.Queries.ListTasksByTablo(r.Context(), tablo.ID) + if err != nil { + slog.Default().Error("tablos detail: ListTasksByTablo failed", "tablo_id", tablo.ID, "err", err) + tasks = []sqlc.Task{} + } + if tasks == nil { + tasks = []sqlc.Task{} + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo).Render(r.Context(), w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks).Render(r.Context(), w) } } @@ -288,7 +299,16 @@ func TabloUpdateHandler(deps TablosDeps) http.HandlerFunc { } // Non-HTMX: render full detail page with errors surfaced using the // authenticated user (not nil) to avoid broken layout (CR-02). - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo).Render(ctx, w) + // Fetch tasks for the kanban board; use empty slice on error. + tasks, tasksErr := deps.Queries.ListTasksByTablo(ctx, tablo.ID) + if tasksErr != nil { + slog.Default().Error("tablos update: ListTasksByTablo failed", "tablo_id", tablo.ID, "err", tasksErr) + tasks = []sqlc.Task{} + } + if tasks == nil { + tasks = []sqlc.Task{} + } + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks).Render(ctx, w) return } diff --git a/backend/templates/layout.templ b/backend/templates/layout.templ index 7fb9941..2b5f1c9 100644 --- a/backend/templates/layout.templ +++ b/backend/templates/layout.templ @@ -49,9 +49,10 @@ templ Layout(title string, user *auth.User, csrfToken string) { { children... } + } diff --git a/backend/templates/tablos.templ b/backend/templates/tablos.templ index 26425f7..17e9a9e 100644 --- a/backend/templates/tablos.templ +++ b/backend/templates/tablos.templ @@ -167,9 +167,10 @@ templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) { } // TabloDetailPage renders the full detail page for a single tablo. -// Includes title zone, description zone, and delete zone. +// Includes title zone, description zone, delete zone, and the kanban board. // UI-SPEC §3 Interaction Contract — GET /tablos/{id}. -templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo) { +// tasks is the pre-fetched list of tasks for this tablo (may be empty slice). +templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task) { @Layout("Tablos — Xtablo", user, csrfToken) {
← Back to tablos @@ -183,6 +184,9 @@ templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo) {
@TabloDeleteButtonFragment(tablo, csrfToken)
+
+ @KanbanBoard(tablo.ID, csrfToken, tasks) +
} } diff --git a/backend/templates/tasks.templ b/backend/templates/tasks.templ new file mode 100644 index 0000000..0ea760e --- /dev/null +++ b/backend/templates/tasks.templ @@ -0,0 +1,224 @@ +package templates + +import ( + "backend/internal/db/sqlc" + "backend/internal/web/ui" + "github.com/google/uuid" + "strconv" +) + +// groupTasksByStatus groups a flat task slice into a map keyed by TaskStatus. +// Returns a map; missing statuses return nil (empty) slices. +func groupTasksByStatus(tasks []sqlc.Task) map[sqlc.TaskStatus][]sqlc.Task { + result := make(map[sqlc.TaskStatus][]sqlc.Task, len(TaskColumns)) + for _, t := range tasks { + result[t.Status] = append(result[t.Status], t) + } + return result +} + +// KanbanBoard renders the outer board container with 4 columns and a hidden +// reorder form. Used by TabloDetailPage below the tablo header section. +// UI-SPEC §1 and D-08. +templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task) { + {{ grouped := groupTasksByStatus(tasks) }} +
+ + for _, status := range TaskColumns { + @KanbanColumn(tabloID, status, grouped[status], csrfToken) + } +
+} + +// KanbanColumn renders a single kanban column: header, task list, and add-task slot. +// UI-SPEC §1 and §2. +templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string) { +
+
+

{ TaskColumnLabels[status] }

+ @ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo}) +
+
+ if len(tasks) == 0 { +

No tasks yet

+ } + for _, task := range tasks { + @TaskCard(tabloID, task, csrfToken) + } +
+
+ @AddTaskTrigger(tabloID, status, csrfToken) +
+
+} + +// TaskCard renders a single task card. The outer wrapper carries class "task-card-zone" +// and id="task-{task.ID}" so HTMX outerHTML swaps round-trip cleanly. +// UI-SPEC §4 and D-08. +templ TaskCard(tabloID uuid.UUID, task sqlc.Task, csrfToken string) { +
+
+
+
+ +
+

{ task.Title }

+
+
+ +
+
+
+} + +// TaskCreateFormFragment renders the inline create form shown when a user clicks +// "+ Add task". Targets #column-{status} for HTMX beforeend swap on submit. +// UI-SPEC §2. +templ TaskCreateFormFragment(tabloID uuid.UUID, status sqlc.TaskStatus, form TaskCreateForm, errs TaskCreateErrors, csrfToken string) { +
+ + @ui.CSRFField(csrfToken) +
+ + @FieldError(errs.Title) +
+
+ @ui.Button(ui.ButtonProps{ + Label: "Save", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "submit", + }) + @ui.Button(ui.ButtonProps{ + Label: "Discard", + Variant: ui.ButtonVariantNeutral, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": "/tablos/" + tabloID.String() + "/tasks/cancel-new?status=" + string(status), + "hx-target": "#add-task-slot-" + string(status), + "hx-swap": "innerHTML", + }, + }) +
+
+} + +// TaskDeleteConfirmFragment renders the delete confirmation dialog for a task. +// Carries class "task-card-zone" so outerHTML round-trips work correctly. +// UI-SPEC §5. +templ TaskDeleteConfirmFragment(tabloID uuid.UUID, task sqlc.Task, csrfToken string) { +
+
+

Delete task?

+

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 task", + }, + }) +
+ @ui.Button(ui.ButtonProps{ + Label: "Keep task", + Variant: ui.ButtonVariantNeutral, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/show", + "hx-target": "closest .task-card-zone", + "hx-swap": "outerHTML", + "aria-label": "Keep task", + }, + }) +
+
+
+} + +// AddTaskTrigger renders the "+ Add task" button that expands to TaskCreateFormFragment. +// Targets #add-task-slot-{status} for innerHTML replacement. +// UI-SPEC §2. +templ AddTaskTrigger(tabloID uuid.UUID, status sqlc.TaskStatus, csrfToken string) { + +} + +// TaskCardOOB renders a new TaskCard AND an OOB swap that resets the add-task +// slot to AddTaskTrigger. Used by TaskCreateHandler to perform both operations +// in a single HTMX response. +// D-08/UI-SPEC §2: OOB swap resets #add-task-slot-{status} after create. +templ TaskCardOOB(status sqlc.TaskStatus, task sqlc.Task, tabloID uuid.UUID, csrfToken string) { + @TaskCard(tabloID, task, csrfToken) +
+ @AddTaskTrigger(tabloID, status, csrfToken) +
+} diff --git a/backend/templates/tasks_forms.go b/backend/templates/tasks_forms.go index 3a7953a..6a957a1 100644 --- a/backend/templates/tasks_forms.go +++ b/backend/templates/tasks_forms.go @@ -1,5 +1,23 @@ package templates +import "backend/internal/db/sqlc" + +// TaskColumns defines the canonical left-to-right column order for the kanban board. +var TaskColumns = []sqlc.TaskStatus{ + sqlc.TaskStatusTodo, + sqlc.TaskStatusInProgress, + sqlc.TaskStatusInReview, + sqlc.TaskStatusDone, +} + +// TaskColumnLabels maps each TaskStatus to its human-readable column header label. +var TaskColumnLabels = map[sqlc.TaskStatus]string{ + sqlc.TaskStatusTodo: "To do", + sqlc.TaskStatusInProgress: "In progress", + sqlc.TaskStatusInReview: "In review", + sqlc.TaskStatusDone: "Done", +} + // TaskCreateForm carries submitted field values back to the template for // repopulation on validation failure. type TaskCreateForm struct {