357 lines
13 KiB
Text
357 lines
13 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, filter EtapeFilter) {
|
|
{{ 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, filter)
|
|
}
|
|
<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, filter EtapeFilter) {
|
|
<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, filter)
|
|
</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, filter EtapeFilter) {
|
|
<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) }/>
|
|
<input type="hidden" name="etape_id" value={ form.EtapeID }/>
|
|
@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) + 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="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) + 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).
|
|
templ TaskCardGone(taskID uuid.UUID) {
|
|
<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.
|
|
templ TaskCardOOB(status sqlc.TaskStatus, task sqlc.Task, tabloID uuid.UUID, csrfToken string, filter EtapeFilter) {
|
|
@TaskCard(tabloID, task, csrfToken)
|
|
<div hx-swap-oob={ "innerHTML:#add-task-slot-" + string(status) }>
|
|
@AddTaskTrigger(tabloID, status, csrfToken, filter)
|
|
</div>
|
|
}
|