diff --git a/cmd/project/create_samples.go b/cmd/project/create_samples.go index 782443ba..c58163d5 100644 --- a/cmd/project/create_samples.go +++ b/cmd/project/create_samples.go @@ -29,16 +29,11 @@ import ( ) //go:embed samples.tmpl -var embedSamplesTmpl string +var embedPromptSamplesTmpl string -// PromptSampleSelection gathers upstream samples to select from -func PromptSampleSelection(ctx context.Context, clients *shared.ClientFactory, samples create.Sampler) (string, error) { - sampleRepos, err := create.GetSampleRepos(samples) - if err != nil { - return "", err - } - - projectTypes := []string{} +// promptSampleSelection gathers upstream samples to select from +func promptSampleSelection(ctx context.Context, clients *shared.ClientFactory, sampleRepos []create.GithubRepo) (string, error) { + filteredRepos := []create.GithubRepo{} selection, err := clients.IO.SelectPrompt(ctx, "Select a language:", []string{ fmt.Sprintf("Bolt for JavaScript %s", style.Secondary("Node.js")), @@ -58,32 +53,16 @@ func PromptSampleSelection(ctx context.Context, clients *shared.ClientFactory, s } else if selection.Prompt { switch selection.Index { case 0: - projectTypes = []string{"bolt-js", "bolt-ts"} + filteredRepos = filterRepos(sampleRepos, "node") case 1: - projectTypes = []string{"bolt-python"} + filteredRepos = filterRepos(sampleRepos, "python") case 2: - projectTypes = []string{"deno"} + filteredRepos = filterRepos(sampleRepos, "deno") } } else if selection.Flag { - switch strings.ToLower(strings.TrimSpace(selection.Option)) { - case "node": - projectTypes = []string{"bolt-js", "bolt-ts"} - case "python": - projectTypes = []string{"bolt-python"} - case "deno": - projectTypes = []string{"deno"} - default: - projectTypes = []string{selection.Option} - } + filteredRepos = filterRepos(sampleRepos, selection.Option) } - filteredRepos := []create.GithubRepo{} - if len(projectTypes) <= 0 { - filteredRepos = sampleRepos - } - for _, language := range projectTypes { - filteredRepos = append(filteredRepos, filterRepos(sampleRepos, language)...) - } sortedRepos := sortRepos(filteredRepos) selectOptions := createSelectOptions(sortedRepos) @@ -95,7 +74,7 @@ func PromptSampleSelection(ctx context.Context, clients *shared.ClientFactory, s Flag: clients.Config.Flags.Lookup("template"), PageSize: 4, // Supports standard terminal height (24 rows) Required: true, - Template: embedSamplesTmpl, + Template: embedPromptSamplesTmpl, }) if err != nil { return "", err @@ -107,14 +86,33 @@ func PromptSampleSelection(ctx context.Context, clients *shared.ClientFactory, s return selectedTemplate, nil } -// filterRepos takes in a list of repositories and returns a filtered list -// based on the prepended runtime/framework naming convention for -// repositories in the Slack Samples Org (ie, deno-*, bolt-js-*, etc.) +// filterRepos returns a list of samples matching the provided project type +// according to the project naming conventions of @slack-samples. +// +// Ex: "node" matches both "bolt-js" and "bolt-ts" prefixed samples. func filterRepos(sampleRepos []create.GithubRepo, projectType string) []create.GithubRepo { filteredRepos := make([]create.GithubRepo, 0) for _, s := range sampleRepos { - if strings.HasPrefix(s.Name, projectType) { - filteredRepos = append(filteredRepos, s) + search := strings.TrimSpace(strings.ToLower(projectType)) + switch search { + case "java": + if strings.HasPrefix(s.Name, "bolt-java") { + filteredRepos = append(filteredRepos, s) + } + case "node": + if strings.HasPrefix(s.Name, "bolt-js") || strings.HasPrefix(s.Name, "bolt-ts") { + filteredRepos = append(filteredRepos, s) + } + case "python": + if strings.HasPrefix(s.Name, "bolt-python") { + filteredRepos = append(filteredRepos, s) + } + case "deno": + fallthrough + default: + if strings.HasPrefix(s.Name, search) || search == "" { + filteredRepos = append(filteredRepos, s) + } } } return filteredRepos diff --git a/cmd/project/create_samples_test.go b/cmd/project/create_samples_test.go index 00ad29ad..a9c7e28c 100644 --- a/cmd/project/create_samples_test.go +++ b/cmd/project/create_samples_test.go @@ -25,6 +25,7 @@ import ( "github.com/slackapi/slack-cli/internal/slackcontext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) var mockGitHubRepos = []create.GithubRepo{ @@ -131,7 +132,9 @@ func TestSamples_PromptSampleSelection(t *testing.T) { clients := shared.NewClientFactory(clientsMock.MockClientFactory()) // Execute test - repoName, err := PromptSampleSelection(ctx, clients, sampler) + samples, err := create.GetSampleRepos(sampler) + require.NoError(t, err) + repoName, err := promptSampleSelection(ctx, clients, samples) assert.Equal(t, tt.expectedError, err) assert.Equal(t, tt.expectedRepository, repoName) }) @@ -139,8 +142,29 @@ func TestSamples_PromptSampleSelection(t *testing.T) { } func TestSamples_FilterRepos(t *testing.T) { - filteredRepos := filterRepos(mockGitHubRepos, "deno") - assert.Equal(t, len(filteredRepos), 2, "Expected filteredRepos length to be 2") + tests := map[string]struct { + language string + expectedRepos int + }{ + "deno matches deno": { + language: "deno", + expectedRepos: 2, + }, + "node matches bolt-js and bolt-ts": { + language: "node", + expectedRepos: 1, + }, + "no filter returns all options": { + language: "", + expectedRepos: 4, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + filteredRepos := filterRepos(mockGitHubRepos, tt.language) + assert.Equal(t, tt.expectedRepos, len(filteredRepos)) + }) + } } func TestSamples_SortRepos(t *testing.T) { diff --git a/cmd/project/create_template.go b/cmd/project/create_template.go index e889ea84..54553e73 100644 --- a/cmd/project/create_template.go +++ b/cmd/project/create_template.go @@ -173,7 +173,11 @@ func promptTemplateSelection(cmd *cobra.Command, clients *shared.ClientFactory) sampler := api.NewHTTPClient(api.HTTPClientOptions{ TotalTimeOut: 60 * time.Second, }) - selectedSample, err := PromptSampleSelection(ctx, clients, sampler) + samples, err := create.GetSampleRepos(sampler) + if err != nil { + return create.Template{}, err + } + selectedSample, err := promptSampleSelection(ctx, clients, samples) if err != nil { return create.Template{}, err } diff --git a/cmd/project/samples.go b/cmd/project/samples.go index af537229..05c3b310 100644 --- a/cmd/project/samples.go +++ b/cmd/project/samples.go @@ -15,9 +15,13 @@ package project import ( + "context" + "fmt" + "strings" "time" "github.com/slackapi/slack-cli/internal/api" + "github.com/slackapi/slack-cli/internal/pkg/create" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/style" "github.com/spf13/cobra" @@ -26,6 +30,7 @@ import ( // Flags var samplesTemplateURLFlag string var samplesGitBranchFlag string +var samplesListFlag bool var samplesLanguageFlag string func NewSamplesCommand(clients *shared.ClientFactory) *cobra.Command { @@ -35,6 +40,10 @@ func NewSamplesCommand(clients *shared.ClientFactory) *cobra.Command { Short: "List available sample apps", Long: "List and create an app from the available samples", Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "List Bolt for JavaScript samples", + Command: "samples --list --language node", + }, { Meaning: "Select a sample app to create", Command: "samples my-project", @@ -50,6 +59,7 @@ func NewSamplesCommand(clients *shared.ClientFactory) *cobra.Command { cmd.Flags().StringVarP(&samplesTemplateURLFlag, "template", "t", "", "template URL for your app") cmd.Flags().StringVarP(&samplesGitBranchFlag, "branch", "b", "", "name of git branch to checkout") cmd.Flags().StringVar(&samplesLanguageFlag, "language", "", "runtime for the app framework\n ex: \"deno\", \"node\", \"python\"") + cmd.Flags().BoolVar(&samplesListFlag, "list", false, "prints samples without interactivity") return cmd } @@ -60,7 +70,18 @@ func runSamplesCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [ sampler := api.NewHTTPClient(api.HTTPClientOptions{ TotalTimeOut: 60 * time.Second, }) - selectedSample, err := PromptSampleSelection(ctx, clients, sampler) + samples, err := create.GetSampleRepos(sampler) + if err != nil { + return err + } + if samplesListFlag || !clients.IO.IsTTY() { + err := listSampleSelection(ctx, clients, samples) + if err != nil { + return err + } + return nil + } + selectedSample, err := promptSampleSelection(ctx, clients, samples) if err != nil { return err } @@ -85,3 +106,55 @@ func runSamplesCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [ // Execute the `create` command with the set flag return createCmd.ExecuteContext(ctx) } + +// listSampleSelection outputs available samples matching a language flag filter +func listSampleSelection(ctx context.Context, clients *shared.ClientFactory, sampleRepos []create.GithubRepo) error { + filteredRepos := filterRepos(sampleRepos, samplesLanguageFlag) + sortedRepos := sortRepos(filteredRepos) + templateRepos := []create.GithubRepo{} + exampleRepos := []create.GithubRepo{} + for _, repo := range sortedRepos { + if strings.Contains(repo.FullName, "template") { + templateRepos = append(templateRepos, repo) + } else { + exampleRepos = append(exampleRepos, repo) + } + } + message := "" + if samplesLanguageFlag != "" { + message = fmt.Sprintf( + "Listing %d \"%s\" templates and project samples", + len(filteredRepos), + samplesLanguageFlag, + ) + } else { + message = fmt.Sprintf("Listing %d template and project samples", len(filteredRepos)) + } + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "toolbox", + Text: "Samples", + Secondary: []string{ + message, + }, + })) + samples := append( + templateRepos, + exampleRepos..., + ) + for _, sample := range samples { + clients.IO.PrintInfo(ctx, false, style.Sectionf(style.TextSection{ + Emoji: "hammer_and_wrench", + Text: fmt.Sprintf( + " %s | %s | %d %s", + style.Bold(sample.Name), + sample.Description, + sample.StargazersCount, + style.Pluralize("star", "stars", sample.StargazersCount), + ), + Secondary: []string{ + fmt.Sprintf("https://github.com/%s", sample.FullName), + }, + })) + } + return nil +} diff --git a/cmd/project/samples_test.go b/cmd/project/samples_test.go index 8207cf4c..b4c51380 100644 --- a/cmd/project/samples_test.go +++ b/cmd/project/samples_test.go @@ -34,6 +34,7 @@ func TestSamplesCommand(t *testing.T) { "creates a template from a trusted sample": { CmdArgs: []string{"my-sample-app"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("IsTTY").Return(true) createPkg.GetSampleRepos = func(client createPkg.Sampler) ([]createPkg.GithubRepo, error) { repos := []createPkg.GithubRepo{ { @@ -97,6 +98,39 @@ func TestSamplesCommand(t *testing.T) { } }, }, + "lists available samples matching a language": { + CmdArgs: []string{"--list", "--language", "node"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("IsTTY").Return(true) + createPkg.GetSampleRepos = func(client createPkg.Sampler) ([]createPkg.GithubRepo, error) { + repos := []createPkg.GithubRepo{ + { + Name: "deno-starter-template", + FullName: "slack-samples/deno-starter-template", + CreatedAt: "2025-02-11T12:34:56Z", + StargazersCount: 4, + Description: "a mock starter template for deno", + Language: "typescript", + }, + { + Name: "bolt-js-starter-template", + FullName: "slack-samples/bolt-js-starter-template", + CreatedAt: "2025-02-11T12:34:56Z", + StargazersCount: 12, + Description: "a mock starter template for bolt js", + Language: "javascript", + }, + } + return repos, nil + } + }, + ExpectedOutputs: []string{ + "https://github.com/slack-samples/bolt-js-starter-template", + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + assert.NotContains(t, cm.GetStdoutOutput(), "deno-starter-template") + }, + }, }, func(cf *shared.ClientFactory) *cobra.Command { return NewSamplesCommand(cf) })