xtablo-source/.planning/phases/20-tablo-detail-kanban-restyle/20-02-PLAN.md
Arthur Belleville f24e1c4d35
test(20-01): add failing tests for TabloDetailViewModel + GetTabloDetailPage handler
- TestComputeTabloProgress_{Empty,AllDone,Half,EtapesIgnored}
- TestNewTabloDetailViewModel_{GroupsTasksByStatus,EtapesExcludedFromColumns,EtapesPopulated}
- TestGetTabloDetailPage_{Returns200,Returns404,Returns400,Unauthenticated}
- TestTabloDetailKanbanColumns
- TestGetTabloDetailPage_ContainsSortableScript
2026-05-18 15:44:53 +02:00

395 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
phase: 20-tablo-detail-kanban-restyle
plan: 02
type: execute
wave: 2
depends_on:
- 20-01
files_modified:
- go-backend/internal/web/views/tablo_detail.templ
- go-backend/internal/web/views/tablo_detail_view.go
- go-backend/internal/web/handlers/tablo_detail_tab.go
autonomous: true
requirements:
- DETAIL-01
- TASK-01
must_haves:
truths:
- "Tablo detail page renders a header with tablo name as h1 (font-size 1.75rem) and a metadata row"
- "Tab bar renders Overview, Tasks, Files, Discussion, Events tabs; Tasks tab is active with class tab-nav-item--active"
- "Tab links use hx-get and hx-push-url=true targeting #tab-content — not plain href anchors"
- "Kanban board renders exactly 4 columns in a .tablo-kanban-board flex container"
- "Each column uses class tablo-kanban-column with data-status attribute set to the column ID"
- "Each column contains a hidden reorder form with id reorder-form-{status} for Sortable.js onEnd"
- "Each task card uses class task-card and carries data-task-id"
- "Drag handle element uses class task-drag-handle and is a child of .task-card"
- "Empty column renders a .tablo-kanban-empty element with text 'Aucune tâche'"
- "Rendered HTML from GET /tablos/{id} contains substring `initTabloDetailSortable`"
- "Etapes section renders below the kanban board listing each etape name and its task count"
artifacts:
- path: "go-backend/internal/web/views/tablo_detail.templ"
provides: "TabloDetailPage + all sub-components (header, tab bar, kanban board, task card, etapes section)"
exports: ["TabloDetailPage"]
- path: "go-backend/internal/web/views/tablo_detail_view.go"
provides: "TabloDetailPage real templ component replaces stub from Plan 01"
contains: "func TabloDetailPage"
- path: "go-backend/internal/web/handlers/tablo_detail_tab.go"
provides: "GET /tablos/{tabloID}/{tab} handler for tab content swaps"
exports: ["GetTabloDetailTab"]
key_links:
- from: "go-backend/internal/web/views/tablo_detail.templ"
to: "go-backend/internal/web/views/tablo_detail_view.go"
via: "TabloDetailViewModel struct fields"
pattern: "TabloDetailViewModel"
- from: ".tablo-kanban-board .sortable-column"
to: "POST /tablos/{id}/tasks/reorder"
via: "Sortable.js onEnd -> #reorder-form-{status} submit"
pattern: "reorder-form-"
- from: ".tablo-tab-bar a[hx-get]"
to: "GET /tablos/{tabloID}/{tab}"
via: "HTMX hx-get + hx-push-url=true"
pattern: "hx-get"
---
<objective>
Build the tablo_detail.templ components that render the tablo detail page: header section, HTMX tab bar, kanban board with 4 columns (each with a hidden reorder form), task cards, empty state, etapes section, and Sortable.js initialization script. Replace the stub TabloDetailPage function in tablo_detail_view.go with the real templ component. Add a minimal tab handler for the tasks tab.
Purpose: This plan produces the full HTML surface for DETAIL-01 and TASK-01. All CSS class names used here are defined in Plan 03's CSS work, so the visual output is unstyled at first but structurally correct.
Output: tablo_detail.templ with all sub-components; tablo_detail_view.go stub removed and replaced by templ-generated component; tablo_detail_tab.go with GetTabloDetailTab handler.
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/20-tablo-detail-kanban-restyle/20-UI-SPEC.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/20-tablo-detail-kanban-restyle/20-RESEARCH.md
<interfaces>
<!-- Key types and contracts the executor needs. -->
From go-backend/internal/web/views/tablo_detail_view.go (created in Plan 01):
```go
type TabloDetailTaskView struct {
ID string
Title string
DeleteHref string
EditHref string
}
type TabloDetailColumnView struct {
ID string // "todo" | "in_progress" | "in_review" | "done"
Label string // "À faire" | "En cours" | "En révision" | "Terminé"
Tasks []TabloDetailTaskView
CreateHref string // "/tablos/{id}/tasks/create?status={colID}"
}
type TabloDetailEtapeView struct {
ID string
Name string
TaskCount int
}
type TabloDetailViewModel struct {
TabloID string
TabloName string
Color string
Initial string
OwnerName string
DueDate string // empty string → show "—" in template
StatusLabel string
StatusTone string // "warning" | "success" | "info"
Progress int // 0-100
ProgressLabel string // "{N}%"
Columns []TabloDetailColumnView // always 4 columns
Etapes []TabloDetailEtapeView // may be empty
}
```
From go-backend/internal/web/ui/badge.templ:
```go
// ui.Badge(ui.BadgeProps{Label: string, Variant: string}) templ.Component
// badge variants: use badgeVariantForTone(tone string) -> string (function in views package)
// tone strings: "success", "warning", "info", "danger", "default"
```
From go-backend/internal/web/ui/icon_button.templ:
```go
// ui.IconButton(ui.IconButtonProps{Label, Icon, Variant, Tone, Type, Attrs}) templ.Component
// IconButtonVariantNeutral / IconButtonToneGhost — for ghost action buttons
// IconButtonVariantDanger + IconButtonToneGhost — for delete button
```
From go-backend/internal/web/views/tablos.templ (for ActionIcon usage):
```go
// ActionIcon(kind string) templ.Component — available in views package
// Icons used: "calendar", "message-circle", "user"
```
From go-backend/internal/web/views/tasks.templ (for drag-and-drop reorder pattern):
```
// Sortable.js init fires on DOMContentLoaded AND htmx:afterSettle
// Uses: .sortable-column class on task list containers
// Uses: data-status attribute on column containers
// Uses: .task-drag-handle handle class
// Uses: .task-card draggable class
// Reorder form: hidden form that submits task order to POST /tablos/{id}/tasks/reorder
// IMPORTANT: form id must be "reorder-form-{status}" — Sortable.js onEnd looks up
// getElementById('reorder-form-' + el.dataset.status)
// Guard: if (el._sortable) return; prevents double-init after HTMX swap (Pitfall 4)
```
CSS class contract (defined in Plan 03, used here):
```
.tablo-detail-page — outer container, px-6 pt-6
.tablo-detail-header — header element
.tablo-detail-title-row — flex row with avatar + h1
.tablo-detail-avatar — 48×48 colored circle with initial
.tablo-detail-title — h1, font-size 1.75rem, weight 600
.tablo-metadata-row — flex row with gap:24px, padding-block:16px
.tablo-meta-segment — each metadata segment in the row
.tablo-meta-progress — the progress segment (bar + label)
.tablo-progress-bar — progress fill (uses brand-primary, NOT project-color)
.tablo-tab-bar — tab navigation below header
.tab-nav-item — each tab item (existing class)
.tab-nav-item--active — active tab (existing class)
.tablo-kanban-board — flex container for columns
.tablo-kanban-column — each 18rem column
.tablo-kanban-column-header — column header area
.tablo-kanban-column-title — h3 inside column header
.tablo-kanban-task-count — count pill
.tablo-kanban-add-link — "+ Ajouter" ghost link
.task-list.sortable-column — the sortable task list container
.task-card — individual task card (replaces .task-row)
.task-card-top-row — row with drag handle, title, delete icon
.task-drag-handle — drag handle (opacity 0 at rest, 1 on card hover)
.task-card-title — task title text
.task-card-delete — delete icon button (opacity 0 at rest)
.tablo-kanban-empty — empty column message
.tablo-etapes-section — etapes section below kanban board
.tablo-etape-row — each etape row (name + task count)
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: TabloDetailPage templ component — header, HTMX tab bar, kanban board, etapes section</name>
<files>go-backend/internal/web/views/tablo_detail.templ, go-backend/internal/web/handlers/tablo_detail_tab.go</files>
<read_first>
- go-backend/internal/web/views/tablo_detail_view.go (TabloDetailViewModel fields — just created in Plan 01)
- go-backend/internal/web/views/tablos.templ (TabloGridCard pattern, ActionIcon usage, badgeVariantForTone function name)
- go-backend/internal/web/views/tasks.templ (TasksKanbanLayout — for contrast; tablo-detail kanban is SEPARATE and must NOT reuse TasksKanbanLayout; also read the Sortable.js init script and reorder form pattern)
- go-backend/internal/web/ui/badge.templ (BadgeProps fields)
- go-backend/internal/web/ui/icon_button.templ (IconButtonProps fields)
- go-backend/internal/web/ui/app.css lines 882-900 (existing .tab-nav, .tab-nav-item classes)
- go-backend/internal/web/views/discussion_view.go (for badgeVariantForTone function — confirm it's in views package)
- go-backend/internal/web/handlers/tablos.go (renderTablosResponse pattern — for how DashboardContentSwapWithMainClass is called, to replicate in tab handler)
</read_first>
<action>
Create go-backend/internal/web/views/tablo_detail.templ in package views. Import "xtablo-backend/internal/web/ui" and "strconv".
Define the following templ components in this order:
1. TabloDetailPage(vm TabloDetailViewModel) — outer wrapper:
- Outer div class="tablo-detail-page"
- @TabloDetailHeader(vm)
- @TabloDetailTabBar(vm.TabloID)
- div id="tab-content":
* @TabloDetailKanbanBoard(vm.Columns, vm.TabloID)
* if len(vm.Etapes) > 0: @TabloDetailEtapesSection(vm.Etapes)
- @TabloDetailSortableScript(vm.TabloID)
2. TabloDetailHeader(vm TabloDetailViewModel) — header section:
- header element class="tablo-detail-header"
- Inner div class="tablo-detail-title-row":
* div class="tablo-detail-avatar" style={ "background:" + vm.Color } containing { vm.Initial }
* h1 class="tablo-detail-title" containing { vm.TabloName }
- div class="tablo-metadata-row":
* span class="tablo-meta-segment" containing: 24×24 avatar div with owner initial + " " + { vm.OwnerName }
* span class="tablo-meta-segment" containing: @ActionIcon("calendar") + if vm.DueDate != "" { vm.DueDate } else { "—" }
* span class="tablo-meta-segment" containing: @ui.Badge(ui.BadgeProps{Label: vm.StatusLabel, Variant: badgeVariantForTone(vm.StatusTone)})
* span class="tablo-meta-segment tablo-meta-progress" containing:
- div class="project-progress-track" style="min-width:120px" with inner div class="tablo-progress-bar" style={ "width:" + vm.ProgressLabel }
- strong { vm.ProgressLabel }
3. TabloDetailTabBar(tabloID string) — HTMX tab navigation:
- nav element class="tablo-tab-bar"
- 5 anchor elements for: "Vue d'ensemble", "Tâches", "Fichiers", "Discussion", "Événements"
- Tab slugs: "overview", "tasks", "files", "discussion", "events"
- Each anchor MUST use HTMX attributes (per UI-SPEC locked interaction contract):
hx-get="/tablos/{tabloID}/{slug}"
hx-target="#tab-content"
hx-push-url="true"
class="tab-nav-item"
- "Tâches" tab (tasks slug): class="tab-nav-item tab-nav-item--active" (Phase 20 always shows tasks tab active)
- Do NOT use plain href="#slug" anchors — the UI-SPEC requires HTMX push-url navigation for all tabs.
- Use templ.SafeURL for the hx-get value to satisfy templ's URL safety requirements.
4. TabloDetailKanbanBoard(columns []TabloDetailColumnView, tabloID string) — renders the kanban board:
- Outer div class="tablo-kanban-board"
- For each column: @TabloDetailKanbanColumn(col, tabloID)
5. TabloDetailKanbanColumn(col TabloDetailColumnView, tabloID string) — single column:
- div class="tablo-kanban-column" data-status={ col.ID }
- div class="tablo-kanban-column-header":
* span class="tablo-kanban-column-title" containing { col.Label }
* span class="tablo-kanban-task-count" containing { strconv.Itoa(len(col.Tasks)) }
* a class="tablo-kanban-add-link" href={ templ.SafeURL(col.CreateHref) } containing "+ Ajouter"
- div id={ "task-list-" + col.ID } class="task-list sortable-column" data-status={ col.ID }:
* if len(col.Tasks) == 0: div class="tablo-kanban-empty" { "Aucune tâche" }
* else: for _, task := range col.Tasks { @TabloDetailTaskCard(task, tabloID) }
- Hidden reorder form (REQUIRED for Sortable.js onEnd):
<form id={ "reorder-form-" + col.ID }
style="display:none"
hx-post={ "/tablos/" + tabloID + "/tasks/reorder?status=" + col.ID }
hx-target={ "#task-list-" + col.ID }
hx-swap="innerHTML">
<!-- Sortable.js onEnd will populate this form's inputs before calling requestSubmit() -->
</form>
- div id={ "create-zone-" + col.ID } (empty create zone for HTMX task create swap)
6. TabloDetailTaskCard(task TabloDetailTaskView, tabloID string) — task card:
- article element: class="task-card" data-task-id={ task.ID }
- div class="task-card-top-row":
* span class="task-drag-handle" aria-hidden="true" { "⠿" }
* span class="task-card-title" { task.Title }
* @ui.IconButton(ui.IconButtonProps{
Label: "Supprimer la tâche", Icon: "trash",
Variant: ui.IconButtonVariantDanger, Tone: ui.IconButtonToneGhost,
Type: "button",
Attrs: templ.Attributes{
"class": "task-card-delete",
"hx-delete": task.DeleteHref,
"hx-target": "#app-main-content",
"hx-swap": "outerHTML",
"hx-confirm": "Supprimer cette tâche ?",
},
})
7. TabloDetailEtapesSection(etapes []TabloDetailEtapeView) — etapes list below kanban:
- section element class="tablo-etapes-section"
- h2 or h3 heading: "Étapes" (French)
- ul element: for each etape, render a li class="tablo-etape-row" containing:
span class="tablo-etape-name" { etape.Name }
span class="tablo-etape-count" { strconv.Itoa(etape.TaskCount) + " tâche(s)" }
8. TabloDetailSortableScript(tabloID string) — JavaScript block:
Use templ.Raw to emit the Sortable.js init script that:
- Defines function initTabloDetailSortable()
- Calls document.querySelectorAll('.sortable-column').forEach(function(el) { if (el._sortable) return; el._sortable = Sortable.create(el, { group: 'tablo-tasks', animation: 150, handle: '.task-drag-handle', draggable: '.task-card', onEnd: function(evt) { var form = document.getElementById('reorder-form-' + el.dataset.status); if (form) form.requestSubmit(); } }); })
- Calls document.addEventListener('DOMContentLoaded', initTabloDetailSortable)
- Calls document.addEventListener('htmx:afterSettle', initTabloDetailSortable)
Wrap in <script> tag.
Create go-backend/internal/web/handlers/tablo_detail_tab.go in package handlers. This handles GET /tablos/{tabloID}/{tab} for HTMX tab swaps:
- Define GetTabloDetailTab() http.HandlerFunc on *AuthHandler
- Auth check; redirect /login on failure
- Parse tabloID from path; 400 on invalid UUID
- Parse tab slug from path (chi: r.PathValue("tab"))
- For tab == "tasks": fetch tablo + tasks, build TabloDetailViewModel, return views.TabloDetailKanbanBoard(vm.Columns, tabloID.String()) rendered as HTMX partial (no dashboard layout wrapper — just the fragment)
- For other tabs ("overview", "files", "discussion", "events"): return a simple HTML fragment: `<div class="tab-coming-soon">Cette section arrive bientôt.</div>`
- Register route in router.go: mux.Get("/tablos/{tabloID}/{tab}", authHandler.GetTabloDetailTab()) — add immediately after the mux.Get("/tablos/{tabloID}", ...) line
CRITICAL rules:
- Tab links MUST use hx-get + hx-target="#tab-content" + hx-push-url="true" — plain href="#slug" anchors are FORBIDDEN per the UI-SPEC interaction contract
- Each kanban column MUST contain a hidden form with id="reorder-form-{col.ID}" — Sortable.js onEnd calls getElementById('reorder-form-' + el.dataset.status)
- Do NOT import or call TasksKanbanLayout — tablo-detail kanban is a separate surface (RESEARCH Anti-Patterns)
- Do NOT add a task view switcher (Board/List/Gantt) — UI-SPEC says this is Phase 21
- All strings in French per UI-SPEC Copywriting Contract
- Use strconv.Itoa for integer→string conversion in templ (not fmt.Sprintf)
- The tablo-progress-bar uses class "tablo-progress-bar" NOT "project-progress-bar" to avoid Pitfall 5 (project-color vs brand-primary)
- import "strconv" in the templ file header
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/go-backend && just generate 2>&1 | tail -5 && go build ./... 2>&1</automated>
</verify>
<done>just generate succeeds (tablo_detail_templ.go generated); go build ./... exits 0; tablo_detail.templ defines TabloDetailPage, TabloDetailHeader, TabloDetailTabBar, TabloDetailKanbanBoard, TabloDetailKanbanColumn, TabloDetailTaskCard, TabloDetailEtapesSection, TabloDetailSortableScript; each column contains a hidden reorder form; tab links use hx-get.</done>
</task>
<task type="auto">
<name>Task 2: Replace TabloDetailPage stub + wire templ component + run handler tests</name>
<files>go-backend/internal/web/views/tablo_detail_view.go, go-backend/router.go</files>
<read_first>
- go-backend/internal/web/views/tablo_detail_view.go (current stub — remove the ComponentFunc stub)
- go-backend/internal/web/views/tablo_detail.templ (just created — TabloDetailPage is now a real templ component)
- go-backend/internal/web/views/tablo_detail_templ.go (generated by just generate — confirm TabloDetailPage signature)
- go-backend/internal/web/handlers/tablo_detail_test.go (handler tests that verify 200 + tablo name in body + "initTabloDetailSortable" in body)
- go-backend/router.go (add GET /tablos/{tabloID}/{tab} route for GetTabloDetailTab)
</read_first>
<action>
Edit go-backend/internal/web/views/tablo_detail_view.go:
- Remove the stub TabloDetailPage function (the templ.ComponentFunc one added in Plan 01 Task 2)
- Remove "context", "fmt", "io", and "github.com/a-h/templ" imports added for the stub (they are no longer needed in the view.go file since TabloDetailPage is now generated by templ from tablo_detail.templ)
- Keep all struct definitions and NewTabloDetailViewModel + computeTabloProgress functions
After removing the stub, the real TabloDetailPage comes from tablo_detail_templ.go (generated by just generate). Verify with go build.
Edit go-backend/router.go:
- Add mux.Get("/tablos/{tabloID}/{tab}", authHandler.GetTabloDetailTab()) immediately after the mux.Get("/tablos/{tabloID}", ...) line added in Plan 01
Then run the full handler tests to confirm the integration is correct:
- TestGetTabloDetailPage_Returns200 should now render real HTML with the tablo name inside the TabloDetailHeader h1
- TestGetTabloDetailPage_ContainsSortableScript should find "initTabloDetailSortable" in the rendered HTML (now from the real templ component's script block)
- Update any test assertions if needed: the response body should contain the tablo name string
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/go-backend && go test ./... -run "TestGetTabloDetailPage|TestComputeTabloProgress|TestNewTabloDetail|TestTabloDetail" -count=1 -v 2>&1 | tail -30</automated>
</verify>
<done>All TestGetTabloDetailPage_* and view model tests pass; go build ./... exits 0; no stub function remains in tablo_detail_view.go; generated tablo_detail_templ.go exports TabloDetailPage(TabloDetailViewModel) templ.Component; router.go contains both /tablos/{tabloID} and /tablos/{tabloID}/{tab} routes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Template rendering | TabloDetailViewModel data flows from handler into templ — ensure no raw HTML injection |
| Tab slug routing | {tab} path value used to select partial response — must not allow arbitrary content injection |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-20-04 | Tampering | TabloDetailTaskCard — task.Title rendered in HTML | mitigate | templ auto-escapes all { expr } interpolations — no raw HTML emission for user data |
| T-20-05 | Tampering | Sortable.js onEnd — data-status attribute used in form ID | mitigate | data-status comes from hardcoded column IDs ("todo", "in_progress", "in_review", "done") — not user input |
| T-20-07 | Tampering | GetTabloDetailTab — {tab} slug used in switch, not reflected into HTML | mitigate | Tab slug used only as switch key; "coming soon" text is hardcoded; tab slug never interpolated into HTML |
| T-20-SC | Tampering | npm/pip/cargo installs | accept | No package installs — pure templ + CSS |
</threat_model>
<verification>
After Plan 02:
- `just generate` exits 0 with tablo_detail_templ.go created
- `go build ./...` exits 0
- `go test ./... -run "TestGetTabloDetailPage|TestComputeTabloProgress|TestNewTabloDetail|TestTabloDetail" -count=1` all pass
- `go test ./... -count=1` full suite green
- tablo_detail.templ defines 8 templ components including TabloDetailPage, TabloDetailTaskCard, TabloDetailEtapesSection
- Task cards use class="task-card" and data-task-id attribute
- Task list containers use class="task-list sortable-column"
- Drag handle uses class="task-drag-handle"
- Empty column uses class="tablo-kanban-empty" with text "Aucune tâche"
- Each column has a hidden form with id="reorder-form-{status}"
- Tab links use hx-get + hx-target="#tab-content" + hx-push-url="true"
- Etapes section renders below kanban board when vm.Etapes is non-empty
</verification>
<success_criteria>
- GET /tablos/{validID} (authenticated) returns 200 with h1 containing tablo name, .tablo-kanban-board div, 4 .tablo-kanban-column elements, hidden reorder forms, and etapes section in the response body
- Full test suite green
- Sortable.js init script present in rendered HTML with DOMContentLoaded and htmx:afterSettle listeners
- Tab links use hx-get targeting #tab-content (not plain href anchors)
- Hidden reorder forms present per column: id="reorder-form-todo", id="reorder-form-in_progress", etc.
- No TasksKanbanLayout referenced in tablo_detail.templ
</success_criteria>
<output>
Create `.planning/phases/20-tablo-detail-kanban-restyle/20-02-SUMMARY.md` when done.
</output>