Skip to content

Commit 2a4bdaa

Browse files
srtaalejmwbrooks
andauthored
feat(charm): adds 'huh' dynamic forms to create command (#362)
Co-authored-by: Michael Brooks <mbrooks@slack-corp.com>
1 parent a96b3f9 commit 2a4bdaa

5 files changed

Lines changed: 443 additions & 1 deletion

File tree

cmd/project/create_template.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"time"
2222

2323
"github.com/slackapi/slack-cli/internal/api"
24+
"github.com/slackapi/slack-cli/internal/experiment"
2425
"github.com/slackapi/slack-cli/internal/iostreams"
2526
"github.com/slackapi/slack-cli/internal/pkg/create"
2627
"github.com/slackapi/slack-cli/internal/shared"
@@ -106,6 +107,16 @@ func promptTemplateSelection(cmd *cobra.Command, clients *shared.ClientFactory,
106107
// Check if a category shortcut was provided
107108
if categoryShortcut == "agent" {
108109
categoryID = "slack-cli#ai-apps"
110+
} else if clients.Config.WithExperimentOn(experiment.Charm) {
111+
result, err := charmPromptTemplateSelection(ctx, clients)
112+
if err != nil {
113+
return create.Template{}, slackerror.ToSlackError(err)
114+
}
115+
if result.CategoryID == viewMoreSamples || result.TemplateRepo == viewMoreSamples {
116+
selectedTemplate = viewMoreSamples
117+
} else {
118+
selectedTemplate = result.TemplateRepo
119+
}
109120
} else {
110121
// Prompt for the category
111122
promptForCategory := "Select an app:"
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package project
16+
17+
import (
18+
"context"
19+
"strings"
20+
21+
"github.com/charmbracelet/huh"
22+
"github.com/slackapi/slack-cli/internal/shared"
23+
"github.com/slackapi/slack-cli/internal/slackerror"
24+
"github.com/slackapi/slack-cli/internal/slacktrace"
25+
"github.com/slackapi/slack-cli/internal/style"
26+
)
27+
28+
// templateSelectionResult holds the user's selections from the dynamic template form.
29+
type templateSelectionResult struct {
30+
CategoryID string // e.g. "slack-cli#getting-started" or viewMoreSamples
31+
TemplateRepo string // e.g. "slack-samples/bolt-js-starter-template"
32+
}
33+
34+
// runForm executes a huh form. It is a package-level variable so tests can
35+
// override the interactive terminal dependency while testing the surrounding logic.
36+
var runForm = func(f *huh.Form) error { return f.Run() }
37+
38+
// buildTemplateSelectionForm constructs a single-screen huh form where the category
39+
// and template selects are in the same group. Changing the category dynamically
40+
// updates the template options via OptionsFunc.
41+
func buildTemplateSelectionForm(clients *shared.ClientFactory, category *string, template *string) *huh.Form {
42+
categoryOptions := getSelectionOptionsForCategory(clients)
43+
var catOpts []huh.Option[string]
44+
for _, opt := range categoryOptions {
45+
catOpts = append(catOpts, huh.NewOption(opt.Title, opt.Repository))
46+
}
47+
48+
categorySelect := huh.NewSelect[string]().
49+
Title("Select an app:").
50+
Options(catOpts...).
51+
Value(category)
52+
53+
templateSelect := huh.NewSelect[string]().
54+
Title("Select a language:").
55+
OptionsFunc(func() []huh.Option[string] {
56+
if *category == viewMoreSamples {
57+
return []huh.Option[string]{
58+
huh.NewOption("Browse sample gallery...", viewMoreSamples),
59+
}
60+
}
61+
62+
options := getSelectionOptions(clients, *category)
63+
var opts []huh.Option[string]
64+
for _, opt := range options {
65+
opts = append(opts, huh.NewOption(opt.Title, opt.Repository))
66+
}
67+
return opts
68+
}, category).
69+
Value(template)
70+
71+
return huh.NewForm(
72+
huh.NewGroup(categorySelect, templateSelect),
73+
).WithTheme(style.ThemeSlack())
74+
}
75+
76+
// charmPromptTemplateSelection runs the dynamic template selection form and returns the result.
77+
func charmPromptTemplateSelection(ctx context.Context, clients *shared.ClientFactory) (templateSelectionResult, error) {
78+
// Print trace with category options
79+
categoryOptions := getSelectionOptionsForCategory(clients)
80+
categoryTitles := make([]string, len(categoryOptions))
81+
for i, opt := range categoryOptions {
82+
categoryTitles[i] = opt.Title
83+
}
84+
clients.IO.PrintTrace(ctx, slacktrace.CreateCategoryOptions, strings.Join(categoryTitles, ", "))
85+
86+
var category string
87+
var template string
88+
err := runForm(buildTemplateSelectionForm(clients, &category, &template))
89+
if err != nil {
90+
return templateSelectionResult{}, slackerror.ToSlackError(err)
91+
}
92+
93+
// Print trace with template options
94+
templateOptions := getSelectionOptions(clients, category)
95+
templateTitles := make([]string, len(templateOptions))
96+
for i, opt := range templateOptions {
97+
templateTitles[i] = opt.Title
98+
}
99+
if len(templateTitles) > 0 {
100+
clients.IO.PrintTrace(ctx, slacktrace.CreateTemplateOptions, strings.Join(templateTitles, ", "))
101+
}
102+
103+
return templateSelectionResult{
104+
CategoryID: category,
105+
TemplateRepo: template,
106+
}, nil
107+
}
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package project
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"testing"
21+
22+
tea "github.com/charmbracelet/bubbletea"
23+
"github.com/charmbracelet/huh"
24+
"github.com/charmbracelet/x/ansi"
25+
"github.com/slackapi/slack-cli/internal/shared"
26+
"github.com/stretchr/testify/assert"
27+
"github.com/stretchr/testify/require"
28+
)
29+
30+
// doAllUpdates recursively processes all commands returned by form updates,
31+
// including batch messages from OptionsFunc evaluations and group transitions.
32+
// This mirrors the helper in huh's own test suite.
33+
func doAllUpdates(f *huh.Form, cmd tea.Cmd) {
34+
if cmd == nil {
35+
return
36+
}
37+
var cmds []tea.Cmd
38+
switch msg := cmd().(type) {
39+
case tea.BatchMsg:
40+
for _, subcommand := range msg {
41+
doAllUpdates(f, subcommand)
42+
}
43+
return
44+
default:
45+
_, result := f.Update(msg)
46+
cmds = append(cmds, result)
47+
}
48+
doAllUpdates(f, tea.Batch(cmds...))
49+
}
50+
51+
func TestBuildTemplateSelectionForm(t *testing.T) {
52+
t.Run("renders category and template on one screen", func(t *testing.T) {
53+
cm := shared.NewClientsMock()
54+
cm.AddDefaultMocks()
55+
clients := shared.NewClientFactory(cm.MockClientFactory())
56+
57+
var category, template string
58+
f := buildTemplateSelectionForm(clients, &category, &template)
59+
doAllUpdates(f, f.Init())
60+
61+
view := ansi.Strip(f.View())
62+
assert.Contains(t, view, "Select an app:")
63+
assert.Contains(t, view, "Starter app")
64+
assert.Contains(t, view, "AI Agent app")
65+
assert.Contains(t, view, "Automation app")
66+
assert.Contains(t, view, "View more samples")
67+
assert.Contains(t, view, "Select a language:")
68+
})
69+
70+
t.Run("selecting a category updates template options", func(t *testing.T) {
71+
cm := shared.NewClientsMock()
72+
cm.AddDefaultMocks()
73+
clients := shared.NewClientFactory(cm.MockClientFactory())
74+
75+
var category, template string
76+
f := buildTemplateSelectionForm(clients, &category, &template)
77+
doAllUpdates(f, f.Init())
78+
79+
// Submit first option (Starter app -> getting-started)
80+
_, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEnter})
81+
doAllUpdates(f, cmd)
82+
83+
view := ansi.Strip(f.View())
84+
assert.Contains(t, view, "Bolt for JavaScript")
85+
assert.Contains(t, view, "Bolt for Python")
86+
})
87+
88+
t.Run("selecting view more samples shows browse option", func(t *testing.T) {
89+
cm := shared.NewClientsMock()
90+
cm.AddDefaultMocks()
91+
clients := shared.NewClientFactory(cm.MockClientFactory())
92+
93+
var category, template string
94+
f := buildTemplateSelectionForm(clients, &category, &template)
95+
doAllUpdates(f, f.Init())
96+
97+
// Navigate down to "View more samples" (4th option, index 3)
98+
_, cmd := f.Update(tea.KeyMsg{Type: tea.KeyDown})
99+
doAllUpdates(f, cmd)
100+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyDown})
101+
doAllUpdates(f, cmd)
102+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyDown})
103+
doAllUpdates(f, cmd)
104+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter})
105+
doAllUpdates(f, cmd)
106+
107+
assert.Equal(t, viewMoreSamples, category)
108+
view := ansi.Strip(f.View())
109+
assert.Contains(t, view, "Browse sample gallery...")
110+
})
111+
112+
t.Run("automation category shows Deno option", func(t *testing.T) {
113+
cm := shared.NewClientsMock()
114+
cm.AddDefaultMocks()
115+
clients := shared.NewClientFactory(cm.MockClientFactory())
116+
117+
var category, template string
118+
f := buildTemplateSelectionForm(clients, &category, &template)
119+
doAllUpdates(f, f.Init())
120+
121+
// Navigate to Automation app (3rd option, index 2) and submit
122+
_, cmd := f.Update(tea.KeyMsg{Type: tea.KeyDown})
123+
doAllUpdates(f, cmd)
124+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyDown})
125+
doAllUpdates(f, cmd)
126+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter})
127+
doAllUpdates(f, cmd)
128+
129+
view := ansi.Strip(f.View())
130+
assert.Contains(t, view, "Deno Slack SDK")
131+
})
132+
133+
t.Run("complete flow selects a template", func(t *testing.T) {
134+
cm := shared.NewClientsMock()
135+
cm.AddDefaultMocks()
136+
clients := shared.NewClientFactory(cm.MockClientFactory())
137+
138+
var category, template string
139+
f := buildTemplateSelectionForm(clients, &category, &template)
140+
doAllUpdates(f, f.Init())
141+
142+
// Select first category (Starter app)
143+
_, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEnter})
144+
doAllUpdates(f, cmd)
145+
// Select first template (Bolt for JavaScript)
146+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter})
147+
doAllUpdates(f, cmd)
148+
149+
assert.Equal(t, "slack-cli#getting-started", category)
150+
assert.Equal(t, "slack-samples/bolt-js-starter-template", template)
151+
})
152+
153+
t.Run("uses Slack theme", func(t *testing.T) {
154+
cm := shared.NewClientsMock()
155+
cm.AddDefaultMocks()
156+
clients := shared.NewClientFactory(cm.MockClientFactory())
157+
158+
var category, template string
159+
f := buildTemplateSelectionForm(clients, &category, &template)
160+
doAllUpdates(f, f.Init())
161+
162+
view := f.View()
163+
assert.Contains(t, view, "┃")
164+
})
165+
}
166+
167+
func TestCharmPromptTemplateSelection(t *testing.T) {
168+
originalRunForm := runForm
169+
t.Cleanup(func() { runForm = originalRunForm })
170+
171+
t.Run("returns selected category and template", func(t *testing.T) {
172+
cm := shared.NewClientsMock()
173+
cm.AddDefaultMocks()
174+
clients := shared.NewClientFactory(cm.MockClientFactory())
175+
176+
runForm = func(f *huh.Form) error {
177+
doAllUpdates(f, f.Init())
178+
// Select first category (Starter app)
179+
_, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEnter})
180+
doAllUpdates(f, cmd)
181+
// Select first template (Bolt for JavaScript)
182+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter})
183+
doAllUpdates(f, cmd)
184+
return nil
185+
}
186+
187+
result, err := charmPromptTemplateSelection(context.Background(), clients)
188+
require.NoError(t, err)
189+
assert.Equal(t, "slack-cli#getting-started", result.CategoryID)
190+
assert.Equal(t, "slack-samples/bolt-js-starter-template", result.TemplateRepo)
191+
})
192+
193+
t.Run("returns error when form fails", func(t *testing.T) {
194+
cm := shared.NewClientsMock()
195+
cm.AddDefaultMocks()
196+
clients := shared.NewClientFactory(cm.MockClientFactory())
197+
198+
runForm = func(f *huh.Form) error {
199+
return fmt.Errorf("user cancelled")
200+
}
201+
202+
_, err := charmPromptTemplateSelection(context.Background(), clients)
203+
require.Error(t, err)
204+
assert.Contains(t, err.Error(), "user cancelled")
205+
})
206+
207+
t.Run("returns view more samples selection", func(t *testing.T) {
208+
cm := shared.NewClientsMock()
209+
cm.AddDefaultMocks()
210+
clients := shared.NewClientFactory(cm.MockClientFactory())
211+
212+
runForm = func(f *huh.Form) error {
213+
doAllUpdates(f, f.Init())
214+
// Navigate to "View more samples" (4th option)
215+
_, cmd := f.Update(tea.KeyMsg{Type: tea.KeyDown})
216+
doAllUpdates(f, cmd)
217+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyDown})
218+
doAllUpdates(f, cmd)
219+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyDown})
220+
doAllUpdates(f, cmd)
221+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter})
222+
doAllUpdates(f, cmd)
223+
// Select "Browse sample gallery..."
224+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter})
225+
doAllUpdates(f, cmd)
226+
return nil
227+
}
228+
229+
result, err := charmPromptTemplateSelection(context.Background(), clients)
230+
require.NoError(t, err)
231+
assert.Equal(t, viewMoreSamples, result.CategoryID)
232+
assert.Equal(t, viewMoreSamples, result.TemplateRepo)
233+
})
234+
235+
t.Run("selects AI agent category and template", func(t *testing.T) {
236+
cm := shared.NewClientsMock()
237+
cm.AddDefaultMocks()
238+
clients := shared.NewClientFactory(cm.MockClientFactory())
239+
240+
runForm = func(f *huh.Form) error {
241+
doAllUpdates(f, f.Init())
242+
// Navigate to "AI Agent app" (2nd option)
243+
_, cmd := f.Update(tea.KeyMsg{Type: tea.KeyDown})
244+
doAllUpdates(f, cmd)
245+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter})
246+
doAllUpdates(f, cmd)
247+
// Select first template (Bolt for JavaScript)
248+
_, cmd = f.Update(tea.KeyMsg{Type: tea.KeyEnter})
249+
doAllUpdates(f, cmd)
250+
return nil
251+
}
252+
253+
result, err := charmPromptTemplateSelection(context.Background(), clients)
254+
require.NoError(t, err)
255+
assert.Equal(t, "slack-cli#ai-apps", result.CategoryID)
256+
assert.Equal(t, "slack-samples/bolt-js-assistant-template", result.TemplateRepo)
257+
})
258+
}

0 commit comments

Comments
 (0)