xtablo-source/.planning/phases/16-tablo-detail/16-04-PLAN.md

263 lines
16 KiB
Markdown
Raw Normal View History

---
phase: 16-tablo-detail
plan: 04
type: execute
wave: 4
depends_on:
- 16-03
files_modified:
- backend/templates/files.templ
autonomous: false
requirements:
- DETAIL-04
must_haves:
truths:
- "Files section uses .overview-section / .overview-section-heading layout with h3 'Files' on left and Upload file button on right"
- "File list uses @ui.Table with Filename / Size / Uploaded / Actions columns"
- "Each file row renders as <tr class='file-row-zone'> with Download and Delete @ui.IconButton actions"
- "FileDeleteConfirmFragment outer element is <tr class='file-row-zone'> (matching element type for outerHTML swap)"
- "Empty state uses @ui.EmptyState with Title 'No files yet' and Description 'Upload your first file to get started.'"
- "All 9 existing file handler tests pass unchanged"
- "Browser walkthrough confirms: files tab shows correct table layout, delete confirm swap works, empty state renders correctly"
artifacts:
- path: backend/templates/files.templ
provides: Restyled FilesTabFragment, fileTableHead, fileTableBody, FileListRow as tr, FileDeleteConfirmFragment as tr
contains: "@ui.Table(ui.TableProps{"
- path: backend/templates/files_templ.go
provides: Generated Go from restyled files.templ
exports: []
key_links:
- from: backend/templates/files.templ FileListRow
to: backend/templates/files.templ FileDeleteConfirmFragment
via: "hx-target='closest .file-row-zone' hx-swap='outerHTML'"
pattern: "class=\"file-row-zone\""
- from: backend/templates/files.templ FilesTabFragment
to: backend/internal/web/ui/table.templ @ui.Table
via: "@ui.Table(ui.TableProps{Head: fileTableHead(), Body: fileTableBody(...)})"
pattern: "ui.Table"
---
<objective>
Restyle the files section (DETAIL-04): replace raw `<ul>`/`<li>` layout with `@ui.Table`, add `.overview-section-heading` header, introduce `@ui.EmptyState`, and convert `FileListRow` and `FileDeleteConfirmFragment` to use `<tr>` as the outer element so the existing HTMX outerHTML delete swap continues to work. Ends with a browser verification checkpoint.
Purpose: Deliver DETAIL-04 — files section uses the table component with consistent row actions.
Output: Restyled `files.templ`; all 9 file tests pass; browser checkpoint approves files tab visual result.
</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>
@.planning/ROADMAP.md
@.planning/phases/16-tablo-detail/16-CONTEXT.md
@.planning/phases/16-tablo-detail/16-RESEARCH.md
@.planning/phases/16-tablo-detail/16-PATTERNS.md
@.planning/phases/16-tablo-detail/16-UI-SPEC.md
@.planning/phases/16-tablo-detail/16-03-SUMMARY.md
<interfaces>
<!-- Key contracts the executor needs. -->
From backend/internal/web/ui/table.templ (verified in RESEARCH.md):
type TableProps struct {
Head templ.Component
Body templ.Component
}
Usage: @ui.Table(ui.TableProps{Head: fileTableHead(), Body: fileTableBody(tabloID, files)})
From backend/internal/web/ui/empty_state.templ (verified):
@ui.EmptyState(ui.EmptyStateProps{Title: "No files yet", Description: "Upload your first file to get started."})
From backend/internal/web/ui/variants.go:
ui.ButtonVariantDefault, ui.ButtonToneSolid, ui.SizeMD
ui.IconButtonVariantNeutral, ui.IconButtonVariantDanger, ui.IconButtonToneGhost
Current files.templ structure (must read file to confirm):
FilesTabFragment: renders <ul> with FileListRow <li> items or FileListEmpty()
FileListRow: <li class="file-row-zone" id="file-{id}"> — must become <tr>
FileDeleteConfirmFragment: <div class="file-row-zone" id="file-{id}"> — must become <tr>
FileListEmpty: standalone empty state component — no longer called; @ui.EmptyState used instead
FileUploadForm: existing form component — triggered from new "Upload file" button via HTMX
HTMX delete flow (Pitfall 2 — CRITICAL):
- FileListRow renders <tr class="file-row-zone" id="file-{id}"> inside @ui.Table's <tbody>
- Trash IconButton: hx-get="/tablos/{tabloID}/files/{fileID}/delete-confirm" hx-target="closest .file-row-zone" hx-swap="outerHTML"
- FileDeleteConfirmFragment must render <tr class="file-row-zone" id="file-{id}"> (same element type for outerHTML swap)
- Both use colspan="4" in the confirm <td> to span all 4 columns
Files table columns: Filename / Size / Uploaded / Actions (4 columns)
formatBytes helper: check if it already exists in files.templ or files_helpers.go (grep for formatBytes); if not in scope, use a simple inline format (file.SizeBytes/1024 + "KB" or similar)
File upload button triggers existing FileUploadForm via HTMX to a #file-upload-slot:
"Upload file" button → hx-get="/tablos/{tabloID}/files/upload-form" hx-target="#file-upload-slot" hx-swap="innerHTML"
div id="file-upload-slot" (empty initially, receives the form fragment)
Download icon button: wrap in <a> tag (download is a navigation, not a form POST):
<a href="/tablos/{tabloID}/files/{fileID}/download" aria-label="Download {file.Filename}">
@ui.IconButton(ui.IconButtonProps{Label: "Download file", Icon: "download", Variant: ui.IconButtonVariantNeutral, Tone: ui.IconButtonToneGhost, Type: "button"})
</a>
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Restyle FilesTabFragment, FileListRow, and FileDeleteConfirmFragment</name>
<files>backend/templates/files.templ</files>
<read_first>
- backend/templates/files.templ (read the FULL file before editing — check FilesTabFragment, FileListRow, FileDeleteConfirmFragment, FileListEmpty structures; find formatBytes helper location; confirm all HTMX attributes on existing delete confirm flow)
- backend/internal/web/ui/table.templ (read to confirm TableProps signature and how Head/Body components are rendered — specifically whether @ui.Table renders a full <table><thead><tbody> or just the body; affects how fileTableHead and fileTableBody must be structured)
- backend/internal/web/ui/empty_state.templ (read to confirm EmptyStateProps fields)
- .planning/phases/16-tablo-detail/16-PATTERNS.md (section "backend/templates/files.templ" — FilesTabFragment new structure, fileTableHead, fileTableBody, FileListRow as tr, FileDeleteConfirmFragment as tr)
</read_first>
<action>
Edit `backend/templates/files.templ` to make the following changes. Read the file before starting.
1. RESTYLE FilesTabFragment: replace the current body with:
- Outer: `div class="overview-section"`
- Header: `div class="overview-section-heading"` containing h3 "Files" on the left and `@ui.Button(ui.ButtonProps{Label: "Upload file", Variant: ui.ButtonVariantDefault, Tone: ui.ButtonToneSolid, Size: ui.SizeMD, Type: "button", Attrs: templ.Attributes{"hx-get": "/tablos/{tablo.ID.String()}/files/upload-form", "hx-target": "#file-upload-slot", "hx-swap": "innerHTML"}})` on the right
- `div id="file-upload-slot"` (empty; receives FileUploadForm on button click)
- Conditional: if len(files) == 0 → `@ui.EmptyState(ui.EmptyStateProps{Title: "No files yet", Description: "Upload your first file to get started."})` else → `@ui.Table(ui.TableProps{Head: fileTableHead(), Body: fileTableBody(tablo.ID, files)})`
2. ADD fileTableHead private templ function (lowercase — not exported):
`templ fileTableHead()` rendering `<tr><th>Filename</th><th>Size</th><th>Uploaded</th><th>Actions</th></tr>`
3. ADD fileTableBody private templ function:
`templ fileTableBody(tabloID uuid.UUID, files []sqlc.TabloFile)` rendering `for _, f := range files { @FileListRow(tabloID, f) }`
4. RESTYLE FileListRow: change the outer element from `<li class="file-row-zone" id="file-{id}">` to `<tr class="file-row-zone" id="file-{id}">`. Inside the `<tr>`:
- `<td>{ file.Filename }</td>`
- `<td>{ formatBytes(file.SizeBytes) }</td>` (use existing formatBytes helper; if not found, format as strconv.FormatInt(file.SizeBytes/1024, 10) + " KB" inline)
- `<td>if file.CreatedAt.Valid { file.CreatedAt.Time.Format("2006-01-02") }</td>`
- `<td>` containing: `<a href="/tablos/{tabloID}/files/{fileID}/download" aria-label="Download {file.Filename}">@ui.IconButton(ui.IconButtonProps{Label: "Download file", Icon: "download", Variant: ui.IconButtonVariantNeutral, Tone: ui.IconButtonToneGhost, Type: "button"})</a>` followed by `@ui.IconButton(ui.IconButtonProps{Label: "Delete file", Icon: "trash", Variant: ui.IconButtonVariantDanger, Tone: ui.IconButtonToneGhost, Type: "button", Attrs: templ.Attributes{"hx-get": "/tablos/{tabloID}/files/{fileID}/delete-confirm", "hx-target": "closest .file-row-zone", "hx-swap": "outerHTML"}})`
5. RESTYLE FileDeleteConfirmFragment: change outer element from `<div class="file-row-zone" id="file-{id}">` to `<tr class="file-row-zone" id="file-{id}">`. Wrap existing confirm dialog content in a single `<td colspan="4">`. Preserve all HTMX confirm/cancel button attributes exactly — do not change the confirm POST URL or cancel reload URL.
6. PRESERVE FileListEmpty function body unchanged (it is no longer called from FilesTabFragment but may be referenced elsewhere; keep it in the file).
7. ADD `"github.com/google/uuid"` to the import block if not already present (needed for fileTableBody parameter type).
After editing, run: `just generate` to regenerate `files_templ.go`.
</action>
<verify>
<automated>grep -c "@ui.Table" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/files.templ && grep -c "@ui.EmptyState" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/files.templ && grep -c "class=\"file-row-zone\"" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/files.templ && grep -c "overview-section-heading" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/files.templ && cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && go build ./backend/... && go test ./backend/internal/web/... -run TestFilesTab -count=1</automated>
</verify>
<acceptance_criteria>
- `files.templ` contains `@ui.Table(ui.TableProps{`
- `files.templ` contains `@ui.EmptyState(ui.EmptyStateProps{Title: "No files yet"`
- `files.templ` contains `class="overview-section-heading"`
- `files.templ` contains `class="file-row-zone"` on a `<tr>` element (not `<li>` or `<div>`)
- `files.templ` FileDeleteConfirmFragment outer element is `<tr class="file-row-zone"` with `<td colspan="4">`
- `files.templ` contains `Icon: "download"` in FileListRow actions column
- `go build ./backend/...` exits 0
- `go test ./backend/internal/web/... -run TestFilesTab -count=1` exits 0 (all file tests pass)
- `go test ./backend/internal/web/... -count=1` exits 0 (no regressions)
</acceptance_criteria>
<done>FilesTabFragment uses @ui.Table and @ui.EmptyState; FileListRow and FileDeleteConfirmFragment use tr; all file tests pass.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Plans 0104 together deliver the complete Phase 16 restyling:
- Plan 01: download + chat icons in UIIcon; CSS Sections 1925 in app.css
- Plan 02: Tablo detail header (project-card-top), metadata row, tab nav (design token classes), desc in overview tab, EtapeStrip removed from TasksTabFragment
- Plan 03: Kanban board with tasks-section layout, etape-grouped task rows, KanbanBoard/Column restyled, handlers updated
- Plan 04 (just completed): Files section with @ui.Table, @ui.EmptyState, download + delete IconButtons
</what-built>
<how-to-verify>
Start the dev server: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just dev` (or `air`)
Navigate to a tablo detail page (e.g. http://localhost:3000/tablos/{some-id}).
Check these items:
**Header (DETAIL-01):**
1. Header shows a colored circle avatar with the first letter of the tablo title
2. Title is inline-editable (click to edit, save with Enter or blur)
3. Action controls appear on the right: Discussion link with chat icon, Invite Member button, trash icon button for Delete
4. No hardcoded purple (#804EEC) hex appears as a visible style artifact — brand purple shows only via CSS tokens
**Metadata row:**
5. Created date appears with a calendar icon and formatted date
6. A purple badge labeled "In progress" appears
7. A progress bar track (gray) appears with a thin colored fill
**Tab nav:**
8. All 5 tabs (Overview, Tasks, Files, Discussion, Events) appear in a clean horizontal row
9. The active tab has a purple underline and bold text; inactive tabs are muted gray
10. Clicking a tab loads its content via HTMX
**Overview tab (DETAIL-01):**
11. Description zone appears inside the Overview tab (not above the tab nav)
12. Click description → edit form appears; save returns to display
**Tasks tab (DETAIL-02 + DETAIL-03):**
13. Three kanban columns (Todo / In Progress / Done) appear side by side
14. Each column has a section header with status label, task count badge, and "Add task" button
15. Tasks within each column appear as row-style cards with a round checkbox + task title + trash icon
16. If etapes are defined: tasks are grouped by etape with a colored dot sub-heading; unassigned tasks appear at the bottom under "No etape"
17. Drag-and-drop reorder still works (drag a task between columns)
18. No EtapeStrip filter pills appear anywhere in the tasks tab
**Files tab (DETAIL-04):**
19. Files section header shows "Files" on the left and "Upload file" button on the right
20. Clicking "Upload file" reveals the upload form inline
21. If files exist: they appear in a clean table with Filename / Size / Uploaded / Actions columns
22. Each file row has a download icon and a trash icon
23. Clicking trash → delete confirm replaces the row; confirm/cancel work
24. If no files: the empty state with "No files yet" appears
If any item above is broken or looks wrong, describe the specific issue.
</how-to-verify>
<resume-signal>Type "approved" if all 24 items pass, or describe specific issues to fix.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Handler → FilesTabFragment | files []sqlc.TabloFile from authenticated DB query; tabloID from URL path validated by loadOwnedTablo |
| HTMX outerHTML swap | delete confirm fragment swaps the .file-row-zone tr; hx-target="closest .file-row-zone" is client-driven but the server validates ownership before returning the confirm fragment |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-04-01 | Tampering | FileListRow download href | accept | Download URL /tablos/{id}/files/{fileID}/download is an authenticated route; file ownership validated server-side; no user-controlled URL injection (template uses templ.SafeURL) |
| T-16-04-02 | Information Disclosure | file.Filename in table | accept | Filename is user-supplied data returned to the same authenticated user who uploaded it; no cross-user exposure possible given ownership model |
| T-16-04-03 | Tampering | FileDeleteConfirmFragment tr element | accept | HTMX outerHTML swap of tr is client-side presentation only; server validates delete ownership at POST handler; confirm fragment is GET-only (no side effect) |
</threat_model>
<verification>
```bash
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source
go test ./backend/internal/web/... -count=1
```
All existing file tests pass. Browser checkpoint approved.
Final phase gate (run with DB):
```bash
TEST_DATABASE_URL='postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable' go test ./... -count=1
```
</verification>
<success_criteria>
- `files.templ` FilesTabFragment uses `.overview-section` + `.overview-section-heading` + `@ui.Table` + `@ui.EmptyState`
- `FileListRow` renders `<tr class="file-row-zone">` with Download + Delete `@ui.IconButton`
- `FileDeleteConfirmFragment` renders `<tr class="file-row-zone">` with `<td colspan="4">`
- `go test ./backend/internal/web/... -count=1` passes (all 9 file tests unchanged)
- Browser checkpoint approved: all 24 visual/interaction items verified
- `TEST_DATABASE_URL=... go test ./... -count=1` passes (full suite green)
</success_criteria>
<output>
After completion, create `.planning/phases/16-tablo-detail/16-04-SUMMARY.md` using the summary template.
</output>