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.
106 lines
3.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|