From bad2fe2a5fe1a5c12da63e254d284f020c9d7a91 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 16 Mar 2023 13:08:55 +0100 Subject: [PATCH 1/6] feat: CLI use multiselect for list(string) --- cli/cliui/parameter.go | 24 +++++++++++++++++++++++- cli/cliui/select.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/cli/cliui/parameter.go b/cli/cliui/parameter.go index e8e651a499d37..f57891a6c8ffd 100644 --- a/cli/cliui/parameter.go +++ b/cli/cliui/parameter.go @@ -1,6 +1,7 @@ package cliui import ( + "encoding/json" "fmt" "strings" @@ -69,7 +70,28 @@ func RichParameter(cmd *cobra.Command, templateVersionParameter codersdk.Templat var err error var value string - if len(templateVersionParameter.Options) > 0 { + if templateVersionParameter.Type == "list(string)" { + // Move the cursor up a single line for nicer display! + _, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A") + + var options []string + err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &options) + if err != nil { + return "", err + } + + values, err := MultiSelect(cmd, options) + if err == nil { + v, err := json.Marshal(&values) + if err != nil { + return "", err + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Prompt.String()+Styles.Field.Render(strings.Join(values, ", "))) + value = string(v) + } + } else if len(templateVersionParameter.Options) > 0 { // Move the cursor up a single line for nicer display! _, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A") var richParameterOption *codersdk.TemplateVersionParameterOption diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 17c056ae4ac8a..718ef06f64f28 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -35,6 +35,21 @@ func init() { {{- template "option" $.IterateOption $ix $option}} {{- end}} {{- end }}` + + survey.MultiSelectQuestionTemplate = ` +{{- define "option"}} + {{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}} + {{- if index .Checked .CurrentOpt.Index }}{{color .Config.Icons.MarkedOption.Format }} {{ .Config.Icons.MarkedOption.Text }} {{else}}{{color .Config.Icons.UnmarkedOption.Format }} {{ .Config.Icons.UnmarkedOption.Text }} {{end}} + {{- color "reset"}} + {{- " "}}{{- .CurrentOpt.Value}} +{{end}} +{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} +{{- if not .ShowAnswer }} + {{- "\n"}} + {{- range $ix, $option := .PageEntries}} + {{- template "option" $.IterateOption $ix $option}} + {{- end}} +{{- end}}` } type SelectOptions struct { @@ -118,6 +133,30 @@ func Select(cmd *cobra.Command, opts SelectOptions) (string, error) { return value, err } +func MultiSelect(cmd *cobra.Command, items []string) ([]string, error) { + // Similar hack is applied to Select() + if flag.Lookup("test.v") != nil { + return items, nil + } + + prompt := &survey.MultiSelect{ + Message: "Options:", + Options: items, + Default: items, + } + + var values []string + err := survey.AskOne(prompt, &values, survey.WithStdio(fileReadWriter{ + Reader: cmd.InOrStdin(), + }, fileReadWriter{ + Writer: cmd.OutOrStdout(), + }, cmd.OutOrStdout())) + if errors.Is(err, terminal.InterruptErr) { + return nil, Canceled + } + return values, err +} + type fileReadWriter struct { io.Reader io.Writer From 3e2d7334e2ba356eb828be66ca06f77b47e49c6b Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 16 Mar 2023 13:19:18 +0100 Subject: [PATCH 2/6] fix --- cli/cliui/select.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 718ef06f64f28..1b6412f51f675 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -140,7 +140,6 @@ func MultiSelect(cmd *cobra.Command, items []string) ([]string, error) { } prompt := &survey.MultiSelect{ - Message: "Options:", Options: items, Default: items, } From a4431a66ecab94b7ac06dccc10117cae8184779b Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 16 Mar 2023 15:18:15 +0100 Subject: [PATCH 3/6] select ui tests --- cli/cliui/select_test.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/cli/cliui/select_test.go b/cli/cliui/select_test.go index 4a7cede5cf92a..c22df1af83097 100644 --- a/cli/cliui/select_test.go +++ b/cli/cliui/select_test.go @@ -86,3 +86,36 @@ func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, err cmd.SetIn(ptty.Input()) return value, cmd.ExecuteContext(context.Background()) } + +func TestMultiSelect(t *testing.T) { + t.Parallel() + t.Run("MultiSelect", func(t *testing.T) { + items := []string{"aaa", "bbb", "ccc"} + + t.Parallel() + ptty := ptytest.New(t) + msgChan := make(chan []string) + go func() { + resp, err := newMultiSelect(ptty, items) + assert.NoError(t, err) + msgChan <- resp + }() + require.Equal(t, items, <-msgChan) + }) +} + +func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) { + var values []string + cmd := &cobra.Command{ + RunE: func(cmd *cobra.Command, args []string) error { + selectedItems, err := cliui.MultiSelect(cmd, items) + if err == nil { + values = selectedItems + } + return err + }, + } + cmd.SetOutput(ptty.Output()) + cmd.SetIn(ptty.Input()) + return values, cmd.ExecuteContext(context.Background()) +} From abf3a3aed63ea000f198af1177add55f3a0082dc Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 16 Mar 2023 15:44:49 +0100 Subject: [PATCH 4/6] cli test --- cli/create_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/cli/create_test.go b/cli/create_test.go index 0cdbf45839bcf..afb37915cc571 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -453,6 +453,8 @@ func TestCreateValidateRichParameters(t *testing.T) { stringParameterName = "string_parameter" stringParameterValue = "abc" + listOfStringsParameterName = "list_of_strings_parameter" + numberParameterName = "number_parameter" numberParameterValue = "7" @@ -468,6 +470,10 @@ func TestCreateValidateRichParameters(t *testing.T) { {Name: stringParameterName, Type: "string", Mutable: true, ValidationRegex: "^[a-z]+$", ValidationError: "this is error"}, } + listOfStringsRichParameters := []*proto.RichParameter{ + {Name: listOfStringsParameterName, Type: "list(string)", Mutable: true, DefaultValue: `["aaa","bbb","ccc"]`}, + } + boolRichParameters := []*proto.RichParameter{ {Name: boolParameterName, Type: "bool", Mutable: true}, } @@ -607,6 +613,43 @@ func TestCreateValidateRichParameters(t *testing.T) { } <-doneChan }) + + t.Run("ValidateListOfStrings", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(listOfStringsRichParameters)) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + assert.NoError(t, err) + }() + + matches := []string{ + listOfStringsParameterName, "", + "aaa, bbb, ccc", "", + "Confirm create?", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + if value != "" { + pty.WriteLine(value) + } + } + <-doneChan + }) } func TestCreateWithGitAuth(t *testing.T) { From fb82fd47797aa74b0047e76cc0209a4fb7e797fb Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 16 Mar 2023 16:13:22 +0100 Subject: [PATCH 5/6] Fix --- cli/create_test.go | 42 ++++++++++++++++++++++++++++++++++ cli/parameter.go | 22 ++++++++++++++---- cli/parameter_internal_test.go | 2 +- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/cli/create_test.go b/cli/create_test.go index afb37915cc571..f3080fb1bf6ac 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -650,6 +650,48 @@ func TestCreateValidateRichParameters(t *testing.T) { } <-doneChan }) + + t.Run("ValidateListOfStrings_YAMLFile", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(listOfStringsRichParameters)) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + tempDir := t.TempDir() + removeTmpDirUntilSuccessAfterTest(t, tempDir) + parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml") + _, _ = parameterFile.WriteString(listOfStringsParameterName + `: + - ddd + - eee + - fff`) + cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name()) + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + assert.NoError(t, err) + }() + + matches := []string{ + "Confirm create?", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + if value != "" { + pty.WriteLine(value) + } + } + <-doneChan + }) } func TestCreateWithGitAuth(t *testing.T) { diff --git a/cli/parameter.go b/cli/parameter.go index d826b1ec69dbf..5db96fb27c31c 100644 --- a/cli/parameter.go +++ b/cli/parameter.go @@ -1,6 +1,7 @@ package cli import ( + "encoding/json" "os" "github.com/spf13/cobra" @@ -15,19 +16,32 @@ import ( // Throws an error if the file name is empty. func createParameterMapFromFile(parameterFile string) (map[string]string, error) { if parameterFile != "" { - parameterMap := make(map[string]string) - parameterFileContents, err := os.ReadFile(parameterFile) if err != nil { return nil, err } - err = yaml.Unmarshal(parameterFileContents, ¶meterMap) - + mapStringInterface := make(map[string]interface{}) + err = yaml.Unmarshal(parameterFileContents, &mapStringInterface) if err != nil { return nil, err } + parameterMap := map[string]string{} + for k, v := range mapStringInterface { + switch val := v.(type) { + case string: + parameterMap[k] = val + case []interface{}: + b, err := json.Marshal(&val) + if err != nil { + return nil, err + } + parameterMap[k] = string(b) + default: + return nil, xerrors.Errorf("invalid parameter type: %T", v) + } + } return parameterMap, nil } diff --git a/cli/parameter_internal_test.go b/cli/parameter_internal_test.go index f1316a43a87ad..81dfcefdf49b2 100644 --- a/cli/parameter_internal_test.go +++ b/cli/parameter_internal_test.go @@ -60,7 +60,7 @@ func TestCreateParameterMapFromFile(t *testing.T) { parameterMapFromFile, err := createParameterMapFromFile(parameterFile.Name()) assert.Nil(t, parameterMapFromFile) - assert.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `region ...` into map[string]string") + assert.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `region ...` into map[string]interface {}") removeTmpDirUntilSuccess(t, tempDir) }) From 39042e170418f6fa61fa8d938f8c01f5b4ed0a78 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 16 Mar 2023 16:30:37 +0100 Subject: [PATCH 6/6] Fix --- cli/parameter.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/parameter.go b/cli/parameter.go index 5db96fb27c31c..9d2853b3d2d03 100644 --- a/cli/parameter.go +++ b/cli/parameter.go @@ -2,6 +2,7 @@ package cli import ( "encoding/json" + "fmt" "os" "github.com/spf13/cobra" @@ -30,8 +31,8 @@ func createParameterMapFromFile(parameterFile string) (map[string]string, error) parameterMap := map[string]string{} for k, v := range mapStringInterface { switch val := v.(type) { - case string: - parameterMap[k] = val + case string, bool, int: + parameterMap[k] = fmt.Sprintf("%v", val) case []interface{}: b, err := json.Marshal(&val) if err != nil {