Merge branch 'mai/kahn/phase-5i-phase-a-design' (phase 5i slice C: kanban view_type with group_by chip strip)
This commit is contained in:
217
web/kanban.go
Normal file
217
web/kanban.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
// Phase 5i Slice C — kanban view_type. Read-only: groups the filtered item
|
||||
// set into columns by the chosen group_by dimension. Drag-to-change-group
|
||||
// is deliberately out of scope (design.md §3 / slice ordering A→B→D→C→E).
|
||||
//
|
||||
// m's Q6 pick (2026-05-26): default group_by = status (active/done/archived).
|
||||
// Other values (area / tag / management) remain selectable via the
|
||||
// group_by chip strip.
|
||||
|
||||
// Kanban group_by values. The DB CHECK constraint on projax.views.group_by
|
||||
// is open-ended (text); the validation list lives here so the handler can
|
||||
// reject typos before they round-trip.
|
||||
const (
|
||||
GroupByStatus = "status"
|
||||
GroupByArea = "area"
|
||||
GroupByTag = "tag"
|
||||
GroupByManagement = "management"
|
||||
)
|
||||
|
||||
var allGroupBy = []string{GroupByStatus, GroupByArea, GroupByTag, GroupByManagement}
|
||||
|
||||
// ParseGroupBy pulls `group_by` from q. Empty / unknown values fall back to
|
||||
// the default (status). Lower-cases for case-insensitive matching.
|
||||
func ParseGroupBy(q url.Values) string {
|
||||
raw := strings.ToLower(strings.TrimSpace(q.Get("group_by")))
|
||||
for _, g := range allGroupBy {
|
||||
if raw == g {
|
||||
return g
|
||||
}
|
||||
}
|
||||
return GroupByStatus
|
||||
}
|
||||
|
||||
// KanbanColumn is one column in the rendered board.
|
||||
type KanbanColumn struct {
|
||||
Key string // the raw group value (status="active", tag="work", …)
|
||||
Label string // human-readable header
|
||||
Items []*store.Item // sorted: pinned-first, then updated_at desc, then title
|
||||
}
|
||||
|
||||
// KanbanBoard is the rendered shape. Columns are ordered by the canonical
|
||||
// per-dimension order (status: active/done/archived; management: mai/self/
|
||||
// external/unmanaged; area + tag: alphabetical with the items they hold).
|
||||
type KanbanBoard struct {
|
||||
GroupBy string
|
||||
Columns []KanbanColumn
|
||||
Total int // total cards across columns
|
||||
}
|
||||
|
||||
// BuildKanbanBoard groups the matched items by groupBy. Pure: takes whatever
|
||||
// list of items the handler filtered, returns the column shape.
|
||||
func BuildKanbanBoard(items []*store.Item, groupBy string) KanbanBoard {
|
||||
if groupBy == "" {
|
||||
groupBy = GroupByStatus
|
||||
}
|
||||
keyer := groupByKeyer(groupBy)
|
||||
byKey := map[string][]*store.Item{}
|
||||
emittedKeys := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, it := range items {
|
||||
for _, k := range keyer(it) {
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
emittedKeys = append(emittedKeys, k)
|
||||
}
|
||||
byKey[k] = append(byKey[k], it)
|
||||
}
|
||||
}
|
||||
// Stable column ordering per dimension.
|
||||
switch groupBy {
|
||||
case GroupByStatus:
|
||||
emittedKeys = orderKeyedFirst(emittedKeys, []string{"active", "done", "archived"})
|
||||
case GroupByManagement:
|
||||
emittedKeys = orderKeyedFirst(emittedKeys, []string{"mai", "self", "external", "unmanaged"})
|
||||
default:
|
||||
sort.Strings(emittedKeys)
|
||||
}
|
||||
board := KanbanBoard{GroupBy: groupBy}
|
||||
for _, k := range emittedKeys {
|
||||
cardItems := byKey[k]
|
||||
sort.SliceStable(cardItems, func(i, j int) bool {
|
||||
a, b := cardItems[i], cardItems[j]
|
||||
if a.Pinned != b.Pinned {
|
||||
return a.Pinned
|
||||
}
|
||||
if !a.UpdatedAt.Equal(b.UpdatedAt) {
|
||||
return a.UpdatedAt.After(b.UpdatedAt)
|
||||
}
|
||||
return a.Title < b.Title
|
||||
})
|
||||
board.Columns = append(board.Columns, KanbanColumn{
|
||||
Key: k,
|
||||
Label: columnLabel(groupBy, k),
|
||||
Items: cardItems,
|
||||
})
|
||||
board.Total += len(cardItems)
|
||||
}
|
||||
return board
|
||||
}
|
||||
|
||||
// groupByKeyer returns a function that maps an item to its column keys (a
|
||||
// slice because tags can put one item in multiple columns).
|
||||
func groupByKeyer(groupBy string) func(*store.Item) []string {
|
||||
switch groupBy {
|
||||
case GroupByStatus:
|
||||
return func(it *store.Item) []string {
|
||||
s := it.Status
|
||||
if s == "" {
|
||||
s = "active"
|
||||
}
|
||||
return []string{s}
|
||||
}
|
||||
case GroupByManagement:
|
||||
return func(it *store.Item) []string {
|
||||
if len(it.Management) == 0 {
|
||||
return []string{"unmanaged"}
|
||||
}
|
||||
out := make([]string, 0, len(it.Management))
|
||||
out = append(out, it.Management...)
|
||||
return out
|
||||
}
|
||||
case GroupByArea:
|
||||
return func(it *store.Item) []string {
|
||||
// First segment of the primary path is the area (dev / work / home …).
|
||||
p := it.PrimaryPath()
|
||||
if p == "" {
|
||||
return []string{"—"}
|
||||
}
|
||||
if idx := strings.IndexByte(p, '.'); idx > 0 {
|
||||
return []string{p[:idx]}
|
||||
}
|
||||
return []string{p}
|
||||
}
|
||||
case GroupByTag:
|
||||
return func(it *store.Item) []string {
|
||||
if len(it.Tags) == 0 {
|
||||
return []string{"untagged"}
|
||||
}
|
||||
return append([]string(nil), it.Tags...)
|
||||
}
|
||||
}
|
||||
return func(it *store.Item) []string { return []string{it.Status} }
|
||||
}
|
||||
|
||||
// columnLabel renders the column header. Status / management get title-case
|
||||
// for legibility; area and tag stay verbatim.
|
||||
func columnLabel(groupBy, key string) string {
|
||||
switch groupBy {
|
||||
case GroupByStatus, GroupByManagement:
|
||||
if key == "" {
|
||||
return "—"
|
||||
}
|
||||
return strings.ToUpper(key[:1]) + key[1:]
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// orderKeyedFirst returns `actual` with `preferred` entries first (in the
|
||||
// preferred order, when present in actual), then any remaining actual
|
||||
// entries appended in input order.
|
||||
func orderKeyedFirst(actual, preferred []string) []string {
|
||||
present := map[string]bool{}
|
||||
for _, k := range actual {
|
||||
present[k] = true
|
||||
}
|
||||
out := make([]string, 0, len(actual))
|
||||
for _, k := range preferred {
|
||||
if present[k] {
|
||||
out = append(out, k)
|
||||
delete(present, k)
|
||||
}
|
||||
}
|
||||
for _, k := range actual {
|
||||
if present[k] {
|
||||
out = append(out, k)
|
||||
delete(present, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GroupByChip is one entry in the group-by chip strip rendered above the
|
||||
// kanban board. Active marks the current group_by; URL is the toggle target.
|
||||
type GroupByChip struct {
|
||||
Label string
|
||||
URL string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// GroupByChips builds the group-by chip strip. base must already include the
|
||||
// `?view_type=kanban` segment; we append `&group_by=<value>` on top.
|
||||
func GroupByChips(base string, filter TreeFilter, current string) []GroupByChip {
|
||||
out := make([]GroupByChip, 0, len(allGroupBy))
|
||||
for _, g := range allGroupBy {
|
||||
u := filter.URLOn(base)
|
||||
if !strings.Contains(u, "?") {
|
||||
u += "?view_type=kanban&group_by=" + g
|
||||
} else {
|
||||
u += "&view_type=kanban&group_by=" + g
|
||||
}
|
||||
out = append(out, GroupByChip{
|
||||
Label: g,
|
||||
URL: u,
|
||||
Active: g == current,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
105
web/kanban_test.go
Normal file
105
web/kanban_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
"templates/tree.tmpl",
|
||||
"templates/tree_section.tmpl",
|
||||
"templates/tree_card.tmpl",
|
||||
"templates/tree_kanban.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -180,6 +181,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/tree_section.tmpl",
|
||||
"templates/tree_card.tmpl",
|
||||
"templates/tree_kanban.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -467,6 +469,10 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
// (no tree structure). Build from items + filter directly rather than
|
||||
// reusing the post-prune `roots` (which still keeps ancestors).
|
||||
cardItems := flatMatchedItems(items, filter, linkKinds)
|
||||
// Phase 5i Slice C: kanban groups the same matched set into columns.
|
||||
groupBy := ParseGroupBy(r.URL.Query())
|
||||
kanban := BuildKanbanBoard(cardItems, groupBy)
|
||||
groupByChips := GroupByChips("/", filter, groupBy)
|
||||
data := map[string]any{
|
||||
"Title": "tree",
|
||||
"Roots": roots,
|
||||
@@ -483,6 +489,9 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
"ViewType": view,
|
||||
"ViewTypeChips": ViewTypeChips("/", filter, view),
|
||||
"CardItems": cardItems,
|
||||
"Kanban": kanban,
|
||||
"GroupBy": groupBy,
|
||||
"GroupByChips": groupByChips,
|
||||
// ActiveTags kept for backwards-compat with the old template path; removed
|
||||
// after the template migrates fully.
|
||||
"ActiveTags": filter.Tags,
|
||||
|
||||
@@ -295,6 +295,25 @@ func TestMultiParentBothPathsRouteToSameRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTreeRendersKanbanWhenViewTypeIsKanban verifies the Phase 5i Slice C
|
||||
// dispatch: GET /?view_type=kanban renders the kanban board (with the
|
||||
// group-by chip strip) instead of the forest. group_by defaults to status.
|
||||
func TestTreeRendersKanbanWhenViewTypeIsKanban(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/?view_type=kanban")
|
||||
if !strings.Contains(body, `class="kanban-board"`) {
|
||||
t.Error("?view_type=kanban should render the kanban board")
|
||||
}
|
||||
if !strings.Contains(body, `class="groupby-chip`) {
|
||||
t.Error("kanban view should render the group-by chip strip")
|
||||
}
|
||||
if strings.Contains(body, `<ul class="forest">`) {
|
||||
t.Error("kanban view should not render the tree forest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTreeRendersCardGridWhenViewTypeIsCard verifies Phase 5i Slice B
|
||||
// dispatch: `?view_type=card` renders the flat tile grid instead of the
|
||||
// forest, and the view-type chip strip is present in either view. Unknown
|
||||
|
||||
@@ -229,6 +229,36 @@ table.classify input, table.classify select { width: 100%; }
|
||||
.tree-card-slug { font-size: 0.78em; }
|
||||
.tree-card-meta { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; font-size: 0.78em; }
|
||||
.tree-card-empty { grid-column: 1 / -1; padding: 24px; color: var(--muted); }
|
||||
/* Phase 5i Slice C — kanban columns + cards. */
|
||||
.kanban-controls { margin: 8px 0; }
|
||||
.groupby-chip {
|
||||
display: inline-block; font-size: 0.78em; padding: 1px 8px; border-radius: 999px;
|
||||
background: var(--surface); border: 1px solid var(--border); color: var(--muted); text-decoration: none;
|
||||
}
|
||||
.groupby-chip:hover { color: var(--fg); border-color: var(--accent); }
|
||||
.kanban-board {
|
||||
display: grid; gap: 12px; padding: 12px 0; overflow-x: auto;
|
||||
grid-auto-flow: column; grid-auto-columns: minmax(220px, 280px);
|
||||
}
|
||||
.kanban-column {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 8px 10px; display: flex; flex-direction: column; gap: 8px;
|
||||
min-height: 120px;
|
||||
}
|
||||
.kanban-col-head {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
border-bottom: 1px solid var(--border); padding-bottom: 6px;
|
||||
}
|
||||
.kanban-col-label { font-weight: 500; }
|
||||
.kanban-cards { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.kanban-card {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.kanban-card-title { font-weight: 500; color: var(--fg); text-decoration: none; display: block; }
|
||||
.kanban-card-title:hover { color: var(--accent); }
|
||||
.kanban-card-meta { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 0; font-size: 0.78em; }
|
||||
.kanban-empty { padding: 24px; }
|
||||
#tree-filterbar small { opacity: 0.75; margin-left: 2px; }
|
||||
.tree-section .empty { padding: 24px; color: var(--muted); }
|
||||
.tree-section .clear { color: var(--bad); }
|
||||
|
||||
46
web/templates/tree_kanban.tmpl
Normal file
46
web/templates/tree_kanban.tmpl
Normal file
@@ -0,0 +1,46 @@
|
||||
{{/*
|
||||
Phase 5i Slice C — kanban view for /tree. Columns by group_by value, cards
|
||||
inside each column. Read-only: no drag-to-change (deferred). Empty filtered
|
||||
set surfaces a friendly empty-state message.
|
||||
*/}}
|
||||
{{define "tree-kanban"}}
|
||||
<div class="kanban-controls chip-row">
|
||||
<span class="muted">group by:</span>
|
||||
{{range .GroupByChips}}
|
||||
<a class="groupby-chip{{if .Active}} chip-on{{end}}"
|
||||
href="{{.URL}}"
|
||||
hx-get="{{.URL}}" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true">{{.Label}}</a>
|
||||
{{end}}
|
||||
<small class="muted">{{.Kanban.Total}} card{{if ne .Kanban.Total 1}}s{{end}}</small>
|
||||
</div>
|
||||
|
||||
{{if .Kanban.Columns}}
|
||||
<div class="kanban-board">
|
||||
{{range .Kanban.Columns}}
|
||||
<section class="kanban-column" data-key="{{.Key}}">
|
||||
<header class="kanban-col-head">
|
||||
<span class="kanban-col-label">{{.Label}}</span>
|
||||
<small class="muted">{{len .Items}}</small>
|
||||
</header>
|
||||
<ul class="kanban-cards">
|
||||
{{range .Items}}
|
||||
<li class="kanban-card">
|
||||
<a class="kanban-card-title" href="/i/{{.PrimaryPath}}">{{.Title}}</a>
|
||||
<p class="kanban-card-meta">
|
||||
<span class="muted">{{.PrimaryPath}}</span>
|
||||
{{range .Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
|
||||
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
|
||||
{{if .Pinned}}<span class="pinned" title="Pinned">★</span>{{end}}
|
||||
</p>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="kanban-empty muted">
|
||||
<em>No items match. Try fewer filters or <a href="/?view_type=kanban">clear filters</a>.</em>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -78,6 +78,8 @@
|
||||
|
||||
{{if eq .ViewType "card"}}
|
||||
{{template "tree-card" .}}
|
||||
{{else if eq .ViewType "kanban"}}
|
||||
{{template "tree-kanban" .}}
|
||||
{{else}}
|
||||
<section class="tree">
|
||||
<ul class="forest">
|
||||
|
||||
@@ -66,10 +66,8 @@ func PageViewTypes(route string) ViewTypeSet {
|
||||
case "/", "tree":
|
||||
return ViewTypeSet{
|
||||
Default: ViewTypeList,
|
||||
// Card joins in slice B; kanban lands in slice C — until then,
|
||||
// `?view_type=kanban` falls back to list with the chip strip
|
||||
// labelling it "coming soon" via the template.
|
||||
Allowed: []string{ViewTypeList, ViewTypeCard},
|
||||
// Slice B: list + card. Slice C: kanban joins.
|
||||
Allowed: []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
|
||||
}
|
||||
case "/dashboard", "dashboard":
|
||||
// Dashboard is locked to its Phase 5h tabbed-tiles surface in slice B.
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestParseViewTypeFallsBackOnUnknown(t *testing.T) {
|
||||
{"/", "", ViewTypeList}, // default for tree
|
||||
{"/", "card", ViewTypeCard}, // allowed on tree
|
||||
{"/", "list", ViewTypeList}, // explicit default
|
||||
{"/", "kanban", ViewTypeList}, // not allowed yet → default
|
||||
{"/", "kanban", ViewTypeKanban}, // unlocked in slice C
|
||||
{"/", "junk", ViewTypeList}, // unknown → default
|
||||
{"/dashboard", "", ViewTypeCard}, // default for dashboard
|
||||
{"/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
|
||||
@@ -55,12 +55,16 @@ func TestViewTypeChipsMarkLockedAndActive(t *testing.T) {
|
||||
if byLabel[ViewTypeList].Active {
|
||||
t.Error("list chip should not be Active when current=card")
|
||||
}
|
||||
if byLabel[ViewTypeList].Locked {
|
||||
t.Error("list is allowed on /; chip should not be Locked")
|
||||
for _, allowed := range []string{ViewTypeList, ViewTypeCard, ViewTypeKanban} {
|
||||
if byLabel[allowed].Locked {
|
||||
t.Errorf("%s is allowed on /; chip should not be Locked", allowed)
|
||||
}
|
||||
}
|
||||
for _, locked := range []string{ViewTypeCalendar, ViewTypeKanban, ViewTypeTimeline} {
|
||||
// Slice C only unlocks kanban; calendar + timeline stay locked on /tree
|
||||
// until cross-route view_type swaps land in a future slice.
|
||||
for _, locked := range []string{ViewTypeCalendar, ViewTypeTimeline} {
|
||||
if !byLabel[locked].Locked {
|
||||
t.Errorf("%s should be Locked on / in slice B (not yet implemented)", locked)
|
||||
t.Errorf("%s should still be Locked on /", locked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user