Files
projax/web/kanban_test.go
mAi bbc7867a35 feat(views): Phase 5i slice C — kanban view_type with group_by chip strip
m's Q6 pick (2026-05-26): kanban groups the filtered set by `status`
(default) / `area` / `tag` / `management`. Read-only — drag-to-change
is parked. Adds the third view_type render on /tree (alongside list and
card from earlier slices); kanban is now unlocked in PageViewTypes("/").

New web/kanban.go owns BuildKanbanBoard + the per-dimension keyer +
column ordering (status: active/done/archived; management: mai/self/
external/unmanaged; area + tag: alphabetical). Within-column order:
pinned-first → updated_at desc → title.

ParseGroupBy + GroupByChips provide the URL-param hookup and the chip
strip rendered above the board. Multi-tag items appear in every tag
column they belong to (deliberate — the kanban surfaces overlap).

Render:
- handleTree builds the kanban board off the same flatMatchedItems the
  card view consumes; cost is one extra grouping pass, no new DB hits.
- New templates/tree_kanban.tmpl: header chip strip + responsive
  column board (horizontal scroll on overflow). Empty filtered set
  surfaces a friendly nudge.

CSS additions cover the column / card layout; existing chip aesthetics
reused for the group-by toggle.

Test updates:
- view_type_test.go: slice B's "kanban locked on /" assertions tightened
  to "kanban unlocked; calendar + timeline still locked on /" — slice C
  is the unlock event for kanban.
- New kanban_test.go: per-dimension grouping (status, tag, area),
  pinned-first ordering, parser fallback.
- server_test.go: end-to-end render — GET /?view_type=kanban produces
  kanban-board markup + group-by chip strip; forest absent.
2026-05-26 13:47:03 +02:00

106 lines
3.5 KiB
Go

package web
import (
"net/url"
"testing"
"time"
"github.com/m/projax/store"
)
// TestBuildKanbanBoardGroupByStatus exercises the default group_by — three
// columns in canonical order (active/done/archived) populated by status.
func TestBuildKanbanBoardGroupByStatus(t *testing.T) {
items := []*store.Item{
{ID: "a", Title: "Active1", Status: "active", Paths: []string{"dev.a"}, UpdatedAt: time.Unix(2, 0)},
{ID: "b", Title: "Active2", Status: "active", Paths: []string{"dev.b"}, UpdatedAt: time.Unix(3, 0), Pinned: true},
{ID: "c", Title: "Done1", Status: "done", Paths: []string{"dev.c"}, UpdatedAt: time.Unix(1, 0)},
}
board := BuildKanbanBoard(items, GroupByStatus)
if got, want := len(board.Columns), 2; got != want {
t.Fatalf("columns = %d, want %d", got, want)
}
if board.Columns[0].Key != "active" {
t.Errorf("first column = %q, want active", board.Columns[0].Key)
}
if board.Columns[1].Key != "done" {
t.Errorf("second column = %q, want done", board.Columns[1].Key)
}
if board.Total != 3 {
t.Errorf("Total = %d, want 3", board.Total)
}
// active column: pinned-first (b), then updated_at desc (a).
if board.Columns[0].Items[0].ID != "b" {
t.Errorf("pinned item should be first; got %q", board.Columns[0].Items[0].ID)
}
if board.Columns[0].Items[1].ID != "a" {
t.Errorf("second active item = %q, want a", board.Columns[0].Items[1].ID)
}
}
// TestBuildKanbanBoardGroupByTag puts an item with multiple tags into multiple
// columns. Columns sort alphabetically (no canonical preference for tags).
func TestBuildKanbanBoardGroupByTag(t *testing.T) {
items := []*store.Item{
{ID: "a", Title: "A", Status: "active", Paths: []string{"a"}, Tags: []string{"work", "dev"}},
{ID: "b", Title: "B", Status: "active", Paths: []string{"b"}, Tags: []string{"dev"}},
{ID: "c", Title: "C", Status: "active", Paths: []string{"c"}, Tags: []string{}},
}
board := BuildKanbanBoard(items, GroupByTag)
keys := map[string]int{}
for _, col := range board.Columns {
keys[col.Key] = len(col.Items)
}
if keys["dev"] != 2 {
t.Errorf("dev column items = %d, want 2", keys["dev"])
}
if keys["work"] != 1 {
t.Errorf("work column items = %d, want 1", keys["work"])
}
if keys["untagged"] != 1 {
t.Errorf("untagged column items = %d, want 1", keys["untagged"])
}
}
// TestBuildKanbanBoardGroupByArea uses the first path segment as the area.
func TestBuildKanbanBoardGroupByArea(t *testing.T) {
items := []*store.Item{
{ID: "a", Title: "A", Status: "active", Paths: []string{"dev.a"}},
{ID: "b", Title: "B", Status: "active", Paths: []string{"work.upc.b"}},
{ID: "c", Title: "C", Status: "active", Paths: []string{"work.c"}},
}
board := BuildKanbanBoard(items, GroupByArea)
keys := map[string]int{}
for _, col := range board.Columns {
keys[col.Key] = len(col.Items)
}
if keys["dev"] != 1 {
t.Errorf("dev column = %d, want 1", keys["dev"])
}
if keys["work"] != 2 {
t.Errorf("work column = %d, want 2", keys["work"])
}
}
// TestParseGroupByFallsBackOnUnknown verifies the parser's defaulting.
func TestParseGroupByFallsBackOnUnknown(t *testing.T) {
cases := map[string]string{
"": GroupByStatus,
"status": GroupByStatus,
"tag": GroupByTag,
"area": GroupByArea,
"management": GroupByManagement,
"MaNaGeMeNt": GroupByManagement,
"made-up": GroupByStatus,
}
for raw, want := range cases {
q := url.Values{}
if raw != "" {
q.Set("group_by", raw)
}
if got := ParseGroupBy(q); got != want {
t.Errorf("ParseGroupBy(%q) = %q, want %q", raw, got, want)
}
}
}