xtablo-source/backend/templates/tasks.templ
Arthur Belleville 18a705c812
fix(16): restore task-drag-handle for Sortable.js drag-and-drop
Phase 16 executor removed the .task-drag-handle div from TaskCard
during restyling. Sortable.js handle: '.task-drag-handle' had no
matching element → dragging completely non-functional.

Restores the grip element with CSS-token styling (no Tailwind).
2026-05-16 23:58:51 +02:00

474 lines
16 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
}
// EtapeGroup holds tasks for one etape within a kanban column.
type EtapeGroup struct {
EtapeID string // "" for unassigned
EtapeTitle string // "No etape" for unassigned
EtapeColor string // "" for unassigned
Tasks []sqlc.Task
}
// groupTasksByEtape groups tasks by etape in declaration order; unassigned last.
// Model: groupTasksByStatus above. Uses uuid.UUID(id.Bytes).String() same as taskEtapeIDString.
func groupTasksByEtape(tasks []sqlc.Task, etapes []sqlc.Etape) []EtapeGroup {
// Collect tasks per etape ID string
tasksByEtapeID := make(map[string][]sqlc.Task)
var unassigned []sqlc.Task
for _, t := range tasks {
if !t.EtapeID.Valid {
unassigned = append(unassigned, t)
} else {
keyStr := uuid.UUID(t.EtapeID.Bytes).String()
tasksByEtapeID[keyStr] = append(tasksByEtapeID[keyStr], t)
}
}
var groups []EtapeGroup
for _, etape := range etapes {
id := etape.ID.String()
ts := tasksByEtapeID[id]
if len(ts) == 0 {
continue
}
groups = append(groups, EtapeGroup{
EtapeID: id,
EtapeTitle: etape.Title,
EtapeColor: "", // Etape struct has no Color field in this schema
Tasks: ts,
})
}
if len(unassigned) > 0 {
groups = append(groups, EtapeGroup{
EtapeID: "",
EtapeTitle: "No etape",
EtapeColor: "",
Tasks: unassigned,
})
}
return groups
}
// EtapeGroupHeader renders the sub-heading row for an etape group within a kanban column.
// "No etape" / unassigned group omits the color dot and uses muted label style.
templ EtapeGroupHeader(group EtapeGroup) {
<div class="etape-group-header">
if group.EtapeColor != "" {
<span class="etape-group-color-dot" style={ "background-color: " + group.EtapeColor }></span>
}
if group.EtapeID == "" {
<span class="etape-group-label is-unassigned">{ group.EtapeTitle }</span>
} else {
<span class="etape-group-label">{ group.EtapeTitle }</span>
}
</div>
}
// 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, filter EtapeFilter, etapes []sqlc.Etape) {
{{ 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" + filter.QueryParam()) }
hx-post={ "/tablos/" + tabloID.String() + "/tasks/reorder" + filter.QueryParam() }
hx-target="#kanban-board"
hx-swap="outerHTML"
class="hidden"
>
@ui.CSRFField(csrfToken)
</form>
for _, status := range TaskColumns {
@KanbanColumn(tabloID, status, grouped[status], csrfToken, filter, etapes)
}
<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.
// Tasks are grouped by etape in declaration order; unassigned tasks appear last.
// UI-SPEC §1 and §2.
templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape) {
<div class="kanban-column">
<div class="tasks-section">
<div class="tasks-section-header">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<h3>{ 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 id={ "add-task-slot-" + string(status) }>
@AddTaskTrigger(tabloID, status, csrfToken, filter)
</div>
</div>
<div
class="task-list sortable-column"
data-status={ string(status) }
id={ "column-" + string(status) }
aria-label={ TaskColumnLabels[status] + " column" }
>
if len(tasks) == 0 {
<p class="task-list-empty">No tasks yet</p>
} else {
{{ groups := groupTasksByEtape(tasks, etapes) }}
for _, group := range groups {
@EtapeGroupHeader(group)
for _, task := range group.Tasks {
@TaskCard(tabloID, task, csrfToken)
}
}
}
</div>
</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-row task-card"
data-task-id={ task.ID.String() }
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 }
>
<div class="task-drag-handle" aria-hidden="true">⠿</div>
<div class="task-check" role="checkbox" aria-checked="false"></div>
<div class="task-body">
<p>{ task.Title }</p>
</div>
@ui.IconButton(ui.IconButtonProps{
Label: "Delete task: " + task.Title,
Icon: "trash",
Variant: ui.IconButtonVariantDanger,
Tone: ui.IconButtonToneGhost,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/delete-confirm",
"hx-target": "closest .task-card-zone",
"hx-swap": "outerHTML",
},
})
</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, etapes []sqlc.Etape, form TaskUpdateForm, errs TaskUpdateErrors, csrfToken string) {
<div class="task-card-zone" id={ "task-" + task.ID.String() }>
{{ selectedEtapeID := taskEtapeIDString(task.EtapeID) }}
<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>
<label class="block text-xs font-medium text-slate-600 mb-1">Etape</label>
<select
name="etape_id"
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm focus:border-slate-500 focus:outline-none"
>
if selectedEtapeID == "" {
<option value="" selected>No etape</option>
} else {
<option value="">No etape</option>
}
for _, etape := range etapes {
if selectedEtapeID == etape.ID.String() {
<option value={ etape.ID.String() } selected>{ etape.Title }</option>
} else {
<option value={ etape.ID.String() }>{ etape.Title }</option>
}
}
</select>
</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, filter EtapeFilter, etapes []sqlc.Etape) {
<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>
<label class="block text-xs font-medium text-slate-600 mb-1">Etape</label>
<select
name="etape_id"
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm focus:border-slate-500 focus:outline-none"
>
if form.EtapeID == "" {
<option value="" selected>No etape</option>
} else {
<option value="">No etape</option>
}
for _, etape := range etapes {
if form.EtapeID == etape.ID.String() {
<option value={ etape.ID.String() } selected>{ etape.Title }</option>
} else {
<option value={ etape.ID.String() }>{ etape.Title }</option>
}
}
</select>
</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) + filter.QuerySuffix(),
"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, filter EtapeFilter) {
<button
type="button"
class="tasks-add-button"
hx-get={ "/tablos/" + tabloID.String() + "/tasks/new?status=" + string(status) + filter.QuerySuffix() }
hx-target={ "#add-task-slot-" + string(status) }
hx-swap="innerHTML"
>+ Add task</button>
}
// TaskCardGone renders an empty zone div with the task's id so HTMX outerHTML
// swap removes the card from the DOM after a successful delete (TASK-06).
// TODO: remove etapes and counts params after Phase 16 cleanup
templ TaskCardGone(taskID uuid.UUID, tabloID uuid.UUID, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape, counts EtapeTaskCounts) {
<div id={ "task-" + taskID.String() } class="task-card-zone"></div>
}
// 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.
// TODO: remove etapes and counts params after Phase 16 cleanup
templ TaskCardOOB(status sqlc.TaskStatus, task sqlc.Task, tabloID uuid.UUID, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape, counts EtapeTaskCounts) {
@TaskCard(tabloID, task, csrfToken)
<div hx-swap-oob={ "innerHTML:#add-task-slot-" + string(status) }>
@AddTaskTrigger(tabloID, status, csrfToken, filter)
</div>
}