xtablo-source/backend/templates/tasks.templ
Arthur Belleville 131c9fd6b3
fix(04): draggable:.task-card-zone — move wrapper not inner card
Sortable.js draggable must match direct children of .sortable-column.
Using .task-card (grandchild) caused Sortable to detach it from its
.task-card-zone wrapper, breaking HTMX OOB swap targets and making
drag appear to do nothing. Changed to .task-card-zone so the full
wrapper moves, keeping id= attributes intact for HTMX round-trips.

Also removed redundant form.dispatchEvent() before htmx.trigger()
which could cause a double submit on reorder.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-15 10:01:34 +02:00

350 lines
12 KiB
Text

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) }}
<div id="kanban-board" class="flex gap-4 overflow-x-auto pb-4">
<form
id="reorder-form"
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks/reorder") }
hx-post={ "/tablos/" + tabloID.String() + "/tasks/reorder" }
hx-target="#kanban-board"
hx-swap="outerHTML"
class="hidden"
>
@ui.CSRFField(csrfToken)
</form>
for _, status := range TaskColumns {
@KanbanColumn(tabloID, status, grouped[status], csrfToken)
}
<script>
(function() {
function initSortable(root) {
var cols = root.querySelectorAll ? root.querySelectorAll(".sortable-column") : [];
if (cols.length === 0) {
cols = document.querySelectorAll(".sortable-column");
}
cols.forEach(function(col) {
if (col._sortableInit) { return; }
col._sortableInit = true;
new Sortable(col, {
group: "kanban",
animation: 150,
handle: ".task-drag-handle",
draggable: ".task-card-zone",
ghostClass: "bg-slate-100",
chosenClass: "opacity-50",
onEnd: function() {
var form = document.getElementById("reorder-form");
form.querySelectorAll("input[name=task_id],input[name=task_col]").forEach(function(el) { el.remove(); });
document.querySelectorAll(".sortable-column").forEach(function(c) {
c.querySelectorAll(".task-card").forEach(function(card) {
var inp = document.createElement("input");
inp.type = "hidden"; inp.name = "task_id"; inp.value = card.dataset.taskId;
form.appendChild(inp);
var colInp = document.createElement("input");
colInp.type = "hidden"; colInp.name = "task_col"; colInp.value = c.dataset.status;
form.appendChild(colInp);
});
});
htmx.trigger(form, "submit");
}
});
});
}
function updateBadges() {
document.querySelectorAll(".sortable-column").forEach(function(col) {
var status = col.dataset.status;
var count = col.querySelectorAll(".task-card").length;
var wrapper = document.getElementById("task-count-badge-" + status);
if (wrapper) {
var inner = wrapper.querySelector("span") || wrapper;
inner.textContent = String(count);
}
});
}
// Initial page load — runs after all defer scripts (htmx + sortable) have executed.
document.addEventListener("DOMContentLoaded", function() {
initSortable(document.body);
updateBadges();
});
// After any HTMX swap — re-init new columns and refresh badge counts.
document.addEventListener("htmx:afterSettle", function() {
initSortable(document.body);
updateBadges();
});
})();
</script>
</div>
}
// 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) {
<div class="flex-shrink-0 w-72">
<div class="bg-slate-100 rounded px-3 py-2 mb-2 flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-700">{ TaskColumnLabels[status] }</h3>
<span id={ "task-count-badge-" + string(status) }>
@ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo})
</span>
</div>
<div
class="sortable-column min-h-16 space-y-2"
data-status={ string(status) }
id={ "column-" + string(status) }
aria-label={ TaskColumnLabels[status] + " column" }
>
if len(tasks) == 0 {
<p class="text-sm text-slate-400 italic px-2 py-1">No tasks yet</p>
}
for _, task := range tasks {
@TaskCard(tabloID, task, csrfToken)
}
</div>
<div id={ "add-task-slot-" + string(status) }>
@AddTaskTrigger(tabloID, status, csrfToken)
</div>
</div>
}
// 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) {
<div class="task-card-zone" id={ "task-" + task.ID.String() }>
<div
class="task-card bg-white rounded border border-slate-200 px-3 py-2 shadow-sm"
data-task-id={ task.ID.String() }
>
<div class="flex items-start justify-between gap-2">
<div class="flex items-start gap-2 flex-1 min-w-0">
<div class="task-drag-handle cursor-grab text-slate-400 select-none mt-0.5" aria-hidden="true">⠿</div>
<div
class="flex-1 min-w-0 cursor-pointer"
hx-get={ "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/edit" }
hx-target="closest .task-card-zone"
hx-swap="outerHTML"
role="button"
aria-label={ "Edit task: " + task.Title }
>
<p class="text-sm text-slate-800 break-words">{ task.Title }</p>
</div>
</div>
<button
type="button"
class="ui-button ui-button-soft-danger-md flex-shrink-0 text-xs"
hx-get={ "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/delete-confirm" }
hx-target="closest .task-card-zone"
hx-swap="outerHTML"
aria-label={ "Delete task: " + task.Title }
>Delete</button>
</div>
</div>
</div>
}
// TaskEditFragment renders the inline edit form for an existing task.
// The outer wrapper carries class="task-card-zone" id="task-{task.ID}" so
// HTMX outerHTML swaps round-trip cleanly with TaskCard (TASK-03).
// UI-SPEC §3.
templ TaskEditFragment(tabloID uuid.UUID, task sqlc.Task, form TaskUpdateForm, errs TaskUpdateErrors, csrfToken string) {
<div class="task-card-zone" id={ "task-" + task.ID.String() }>
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks/" + task.ID.String()) }
hx-post={ "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() }
hx-target="closest .task-card-zone"
hx-swap="outerHTML"
class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2"
>
@ui.CSRFField(csrfToken)
<div>
<input
type="text"
name="title"
value={ form.Title }
maxlength="255"
required
placeholder="Task title"
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
/>
@FieldError(errs.Title)
</div>
<div>
<textarea
name="description"
rows="3"
placeholder="Description (optional)"
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
>{ form.Description }</textarea>
</div>
if errs.General != "" {
@FieldError(errs.General)
}
<div class="flex items-center gap-2">
@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/" + tabloID.String() + "/tasks/" + task.ID.String() + "/show",
"hx-target": "closest .task-card-zone",
"hx-swap": "outerHTML",
},
})
</div>
</form>
</div>
}
// 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) {
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks") }
hx-post={ "/tablos/" + tabloID.String() + "/tasks" }
hx-target={ "#column-" + string(status) }
hx-swap="beforeend"
class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2"
>
<input type="hidden" name="status" value={ string(status) }/>
@ui.CSRFField(csrfToken)
<div>
<input
type="text"
name="title"
value={ form.Title }
maxlength="255"
required
placeholder="Task title"
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
/>
@FieldError(errs.Title)
</div>
<div class="flex items-center gap-2">
@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",
},
})
</div>
</form>
}
// 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) {
<div class="task-card-zone" id={ "task-" + task.ID.String() }>
<div class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2">
<p class="text-sm font-semibold text-slate-800">Delete task?</p>
<p class="text-xs text-slate-600">This cannot be undone.</p>
<div class="flex items-center gap-2">
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/delete") }
hx-post={ "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/delete" }
hx-target="closest .task-card-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 task",
},
})
</form>
@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",
},
})
</div>
</div>
</div>
}
// 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) {
<button
type="button"
class="ui-button ui-button-soft-neutral-md w-full text-left text-sm mt-2"
hx-get={ "/tablos/" + tabloID.String() + "/tasks/new?status=" + string(status) }
hx-target={ "#add-task-slot-" + string(status) }
hx-swap="innerHTML"
>+ Add task</button>
}
// 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)
<div hx-swap-oob={ "innerHTML:#add-task-slot-" + string(status) }>
@AddTaskTrigger(tabloID, status, csrfToken)
</div>
}