feat(templates): categorize WorkspaceTemplate + hide demo (TASK-610) (#142)

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.
This commit is contained in:
xarmian
2026-04-18 00:24:22 -04:00
committed by GitHub
parent f222131dfe
commit 73a6e1f3a9
3 changed files with 129 additions and 2 deletions
+59 -2
View File
@@ -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
+66
View File
@@ -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)
}
}
}
+4
View File
@@ -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,
})
}