From 73a6e1f3a98dd017ceca3059d0b77bc0dd4040de Mon Sep 17 00:00:00 2001 From: xarmian Date: Sat, 18 Apr 2026 00:24:22 -0400 Subject: [PATCH] feat(templates): categorize WorkspaceTemplate + hide demo (TASK-610) (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the WorkspaceTemplate struct to carry the metadata and domain- specific seed packs needed for the upcoming non-software templates. - Add Category, Icon, Hidden, Conventions, Playbooks fields to the WorkspaceTemplate struct. Existing fields (Name, Description, Collections, SeedItems) unchanged. - Define SeedConvention and SeedPlaybook types so templates can carry domain-specific rules and workflows (populated in a follow-up task). - Introduce category constants (software, people, research, content, operations, personal). - Assign Category=software and Icon to startup (🚀), scrum (🏃), product (📦). Mark demo (🎬) as Hidden so it no longer appears in the picker while remaining buildable by explicit --template demo. - Split ListTemplates() into a filtered picker view and a new ListAllTemplates() for internal tooling. - Expose category and icon on the /workspaces/templates API response so the web picker can group by category in a follow-up task. - Add package tests for hidden-filtering and picker metadata invariants (the package previously had no tests). Parent: PLAN-609. --- internal/collections/templates.go | 61 +++++++++++++++++++++++- internal/collections/templates_test.go | 66 ++++++++++++++++++++++++++ internal/server/handlers_workspaces.go | 4 ++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 internal/collections/templates_test.go diff --git a/internal/collections/templates.go b/internal/collections/templates.go index 96d4c711..051119e1 100644 --- a/internal/collections/templates.go +++ b/internal/collections/templates.go @@ -2,13 +2,30 @@ package collections import "github.com/xarmian/pad/internal/models" +// Template categories. Templates are grouped in the picker by category so +// users can find the right starting point regardless of whether they are +// building software, running a hiring loop, doing research, etc. +const ( + CategorySoftware = "software" + CategoryPeople = "people" + CategoryResearch = "research" + CategoryContent = "content" + CategoryOperations = "operations" + CategoryPersonal = "personal" +) + // WorkspaceTemplate is a named set of collection definitions used to // initialize a new workspace. type WorkspaceTemplate struct { Name string + Category string // e.g. CategorySoftware, CategoryPeople — used to group templates in the picker Description string + Icon string // Display icon for pickers (CLI + web) + Hidden bool // If true, template is excluded from the picker but still buildable by explicit name Collections []DefaultCollection - SeedItems []SeedItem // Optional sample items to create after collections + Conventions []SeedConvention // Domain-specific conventions seeded with the workspace + Playbooks []SeedPlaybook // Domain-specific playbooks seeded with the workspace + SeedItems []SeedItem // Optional sample items to create after collections } // SeedItem defines a sample item to seed into a workspace. @@ -19,6 +36,22 @@ type SeedItem struct { Fields string // JSON string of field values } +// SeedConvention defines a convention seeded into a workspace when a template +// is applied. It targets the workspace's "conventions" collection. +type SeedConvention struct { + Title string + Content string + Fields string // JSON string of field values (trigger, scope, priority, status, role) +} + +// SeedPlaybook defines a playbook seeded into a workspace when a template is +// applied. It targets the workspace's "playbooks" collection. +type SeedPlaybook struct { + Title string + Content string + Fields string // JSON string of field values (trigger, scope, status) +} + // docsCollection returns the standard Docs collection shared across templates. func docsCollection(sortOrder int) DefaultCollection { return DefaultCollection{ @@ -155,12 +188,16 @@ func playbooksCollection(sortOrder int) DefaultCollection { var templates = []WorkspaceTemplate{ { Name: "startup", + Category: CategorySoftware, Description: "Tasks, Ideas, Plans, Docs, Conventions, Playbooks", + Icon: "\U0001F680", // 🚀 Collections: Defaults(), }, { Name: "scrum", + Category: CategorySoftware, Description: "Backlog, Sprints, Bugs, Docs, Conventions, Playbooks", + Icon: "\U0001F3C3", // 🏃 Collections: []DefaultCollection{ { Name: "Backlog", @@ -291,7 +328,9 @@ var templates = []WorkspaceTemplate{ }, { Name: "product", + Category: CategorySoftware, Description: "Features, Feedback, Roadmap Items, Docs, Conventions, Playbooks", + Icon: "\U0001F4E6", // 📦 Collections: []DefaultCollection{ { Name: "Features", @@ -417,7 +456,10 @@ var templates = []WorkspaceTemplate{ }, { Name: "demo", + Category: CategorySoftware, Description: "Fully populated workspace — see every feature in 30 seconds", + Icon: "\U0001F3AC", // 🎬 + Hidden: true, // Excluded from the picker; still buildable via explicit --template demo Collections: Defaults(), SeedItems: demoSeedItems(), }, @@ -577,8 +619,23 @@ func GetTemplate(name string) *WorkspaceTemplate { return nil } -// ListTemplates returns all available workspace templates. +// ListTemplates returns all workspace templates that should be shown in +// pickers. Templates flagged Hidden are excluded. func ListTemplates() []WorkspaceTemplate { + result := make([]WorkspaceTemplate, 0, len(templates)) + for _, t := range templates { + if t.Hidden { + continue + } + result = append(result, t) + } + return result +} + +// ListAllTemplates returns every registered template, including ones flagged +// Hidden. Intended for internal tooling (e.g. tests, demo seeding) that needs +// to see the full set. +func ListAllTemplates() []WorkspaceTemplate { result := make([]WorkspaceTemplate, len(templates)) copy(result, templates) return result diff --git a/internal/collections/templates_test.go b/internal/collections/templates_test.go new file mode 100644 index 00000000..1c8699c7 --- /dev/null +++ b/internal/collections/templates_test.go @@ -0,0 +1,66 @@ +package collections + +import "testing" + +// TestListTemplatesExcludesHidden verifies that ListTemplates filters out +// templates flagged Hidden while ListAllTemplates still returns them. This +// guards the picker behavior that hides the demo template. +func TestListTemplatesExcludesHidden(t *testing.T) { + visible := ListTemplates() + all := ListAllTemplates() + + if len(all) <= len(visible) { + t.Fatalf("expected ListAllTemplates (%d) to contain more templates than ListTemplates (%d) when at least one template is hidden", len(all), len(visible)) + } + + for _, tmpl := range visible { + if tmpl.Hidden { + t.Errorf("ListTemplates returned hidden template %q", tmpl.Name) + } + } + + // Demo is hidden today; make sure that invariant holds. + for _, tmpl := range visible { + if tmpl.Name == "demo" { + t.Errorf("ListTemplates returned the demo template, which should be hidden") + } + } + foundDemo := false + for _, tmpl := range all { + if tmpl.Name == "demo" { + foundDemo = true + if !tmpl.Hidden { + t.Errorf("demo template should be flagged Hidden") + } + break + } + } + if !foundDemo { + t.Errorf("ListAllTemplates did not return the demo template") + } +} + +// TestGetTemplateReturnsHidden verifies that GetTemplate still resolves hidden +// templates by explicit name. Hiding is about discovery, not access. +func TestGetTemplateReturnsHidden(t *testing.T) { + tmpl := GetTemplate("demo") + if tmpl == nil { + t.Fatal("GetTemplate(\"demo\") returned nil; hidden templates must still be buildable by explicit name") + } + if !tmpl.Hidden { + t.Errorf("demo template should be flagged Hidden") + } +} + +// TestBuiltinTemplatesHaveCategoryAndIcon verifies every visible template is +// assigned a category and icon — these power the categorized picker. +func TestBuiltinTemplatesHaveCategoryAndIcon(t *testing.T) { + for _, tmpl := range ListTemplates() { + if tmpl.Category == "" { + t.Errorf("template %q has empty Category", tmpl.Name) + } + if tmpl.Icon == "" { + t.Errorf("template %q has empty Icon", tmpl.Name) + } + } +} diff --git a/internal/server/handlers_workspaces.go b/internal/server/handlers_workspaces.go index 06ae6a7e..c92224e9 100644 --- a/internal/server/handlers_workspaces.go +++ b/internal/server/handlers_workspaces.go @@ -109,7 +109,9 @@ func (s *Server) handleHealthReady(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) { type templateInfo struct { Name string `json:"name"` + Category string `json:"category"` Description string `json:"description"` + Icon string `json:"icon"` Collections []string `json:"collections"` } templates := collections.ListTemplates() @@ -121,7 +123,9 @@ func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) { } result = append(result, templateInfo{ Name: t.Name, + Category: t.Category, Description: t.Description, + Icon: t.Icon, Collections: colls, }) }