mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user