Skip to content

Commit 1f9ae15

Browse files
authored
feat: CLI use multiselect for list(string) (#6631)
* feat: CLI use multiselect for list(string) * fix * select ui tests * cli test * Fix * Fix
1 parent 95177ad commit 1f9ae15

File tree

6 files changed

+199
-6
lines changed

6 files changed

+199
-6
lines changed

cli/cliui/parameter.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cliui
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"strings"
67

@@ -69,7 +70,28 @@ func RichParameter(cmd *cobra.Command, templateVersionParameter codersdk.Templat
6970

7071
var err error
7172
var value string
72-
if len(templateVersionParameter.Options) > 0 {
73+
if templateVersionParameter.Type == "list(string)" {
74+
// Move the cursor up a single line for nicer display!
75+
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A")
76+
77+
var options []string
78+
err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &options)
79+
if err != nil {
80+
return "", err
81+
}
82+
83+
values, err := MultiSelect(cmd, options)
84+
if err == nil {
85+
v, err := json.Marshal(&values)
86+
if err != nil {
87+
return "", err
88+
}
89+
90+
_, _ = fmt.Fprintln(cmd.OutOrStdout())
91+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Prompt.String()+Styles.Field.Render(strings.Join(values, ", ")))
92+
value = string(v)
93+
}
94+
} else if len(templateVersionParameter.Options) > 0 {
7395
// Move the cursor up a single line for nicer display!
7496
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A")
7597
var richParameterOption *codersdk.TemplateVersionParameterOption

cli/cliui/select.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ func init() {
3535
{{- template "option" $.IterateOption $ix $option}}
3636
{{- end}}
3737
{{- end }}`
38+
39+
survey.MultiSelectQuestionTemplate = `
40+
{{- define "option"}}
41+
{{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}}
42+
{{- 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}}
43+
{{- color "reset"}}
44+
{{- " "}}{{- .CurrentOpt.Value}}
45+
{{end}}
46+
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
47+
{{- if not .ShowAnswer }}
48+
{{- "\n"}}
49+
{{- range $ix, $option := .PageEntries}}
50+
{{- template "option" $.IterateOption $ix $option}}
51+
{{- end}}
52+
{{- end}}`
3853
}
3954

4055
type SelectOptions struct {
@@ -118,6 +133,29 @@ func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
118133
return value, err
119134
}
120135

136+
func MultiSelect(cmd *cobra.Command, items []string) ([]string, error) {
137+
// Similar hack is applied to Select()
138+
if flag.Lookup("test.v") != nil {
139+
return items, nil
140+
}
141+
142+
prompt := &survey.MultiSelect{
143+
Options: items,
144+
Default: items,
145+
}
146+
147+
var values []string
148+
err := survey.AskOne(prompt, &values, survey.WithStdio(fileReadWriter{
149+
Reader: cmd.InOrStdin(),
150+
}, fileReadWriter{
151+
Writer: cmd.OutOrStdout(),
152+
}, cmd.OutOrStdout()))
153+
if errors.Is(err, terminal.InterruptErr) {
154+
return nil, Canceled
155+
}
156+
return values, err
157+
}
158+
121159
type fileReadWriter struct {
122160
io.Reader
123161
io.Writer

cli/cliui/select_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,36 @@ func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, err
8686
cmd.SetIn(ptty.Input())
8787
return value, cmd.ExecuteContext(context.Background())
8888
}
89+
90+
func TestMultiSelect(t *testing.T) {
91+
t.Parallel()
92+
t.Run("MultiSelect", func(t *testing.T) {
93+
items := []string{"aaa", "bbb", "ccc"}
94+
95+
t.Parallel()
96+
ptty := ptytest.New(t)
97+
msgChan := make(chan []string)
98+
go func() {
99+
resp, err := newMultiSelect(ptty, items)
100+
assert.NoError(t, err)
101+
msgChan <- resp
102+
}()
103+
require.Equal(t, items, <-msgChan)
104+
})
105+
}
106+
107+
func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
108+
var values []string
109+
cmd := &cobra.Command{
110+
RunE: func(cmd *cobra.Command, args []string) error {
111+
selectedItems, err := cliui.MultiSelect(cmd, items)
112+
if err == nil {
113+
values = selectedItems
114+
}
115+
return err
116+
},
117+
}
118+
cmd.SetOutput(ptty.Output())
119+
cmd.SetIn(ptty.Input())
120+
return values, cmd.ExecuteContext(context.Background())
121+
}

cli/create_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
453453
stringParameterName = "string_parameter"
454454
stringParameterValue = "abc"
455455

456+
listOfStringsParameterName = "list_of_strings_parameter"
457+
456458
numberParameterName = "number_parameter"
457459
numberParameterValue = "7"
458460

@@ -468,6 +470,10 @@ func TestCreateValidateRichParameters(t *testing.T) {
468470
{Name: stringParameterName, Type: "string", Mutable: true, ValidationRegex: "^[a-z]+$", ValidationError: "this is error"},
469471
}
470472

473+
listOfStringsRichParameters := []*proto.RichParameter{
474+
{Name: listOfStringsParameterName, Type: "list(string)", Mutable: true, DefaultValue: `["aaa","bbb","ccc"]`},
475+
}
476+
471477
boolRichParameters := []*proto.RichParameter{
472478
{Name: boolParameterName, Type: "bool", Mutable: true},
473479
}
@@ -607,6 +613,85 @@ func TestCreateValidateRichParameters(t *testing.T) {
607613
}
608614
<-doneChan
609615
})
616+
617+
t.Run("ValidateListOfStrings", func(t *testing.T) {
618+
t.Parallel()
619+
620+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
621+
user := coderdtest.CreateFirstUser(t, client)
622+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(listOfStringsRichParameters))
623+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
624+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
625+
626+
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
627+
clitest.SetupConfig(t, client, root)
628+
doneChan := make(chan struct{})
629+
pty := ptytest.New(t)
630+
cmd.SetIn(pty.Input())
631+
cmd.SetOut(pty.Output())
632+
go func() {
633+
defer close(doneChan)
634+
err := cmd.Execute()
635+
assert.NoError(t, err)
636+
}()
637+
638+
matches := []string{
639+
listOfStringsParameterName, "",
640+
"aaa, bbb, ccc", "",
641+
"Confirm create?", "yes",
642+
}
643+
for i := 0; i < len(matches); i += 2 {
644+
match := matches[i]
645+
value := matches[i+1]
646+
pty.ExpectMatch(match)
647+
if value != "" {
648+
pty.WriteLine(value)
649+
}
650+
}
651+
<-doneChan
652+
})
653+
654+
t.Run("ValidateListOfStrings_YAMLFile", func(t *testing.T) {
655+
t.Parallel()
656+
657+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
658+
user := coderdtest.CreateFirstUser(t, client)
659+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(listOfStringsRichParameters))
660+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
661+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
662+
663+
tempDir := t.TempDir()
664+
removeTmpDirUntilSuccessAfterTest(t, tempDir)
665+
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
666+
_, _ = parameterFile.WriteString(listOfStringsParameterName + `:
667+
- ddd
668+
- eee
669+
- fff`)
670+
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
671+
clitest.SetupConfig(t, client, root)
672+
doneChan := make(chan struct{})
673+
pty := ptytest.New(t)
674+
cmd.SetIn(pty.Input())
675+
cmd.SetOut(pty.Output())
676+
go func() {
677+
defer close(doneChan)
678+
err := cmd.Execute()
679+
assert.NoError(t, err)
680+
}()
681+
682+
matches := []string{
683+
"Confirm create?", "yes",
684+
}
685+
for i := 0; i < len(matches); i += 2 {
686+
match := matches[i]
687+
value := matches[i+1]
688+
pty.ExpectMatch(match)
689+
if value != "" {
690+
pty.WriteLine(value)
691+
}
692+
}
693+
<-doneChan
694+
})
610695
}
611696

612697
func TestCreateWithGitAuth(t *testing.T) {

cli/parameter.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cli
22

33
import (
4+
"encoding/json"
5+
"fmt"
46
"os"
57

68
"github.com/spf13/cobra"
@@ -15,19 +17,32 @@ import (
1517
// Throws an error if the file name is empty.
1618
func createParameterMapFromFile(parameterFile string) (map[string]string, error) {
1719
if parameterFile != "" {
18-
parameterMap := make(map[string]string)
19-
2020
parameterFileContents, err := os.ReadFile(parameterFile)
2121
if err != nil {
2222
return nil, err
2323
}
2424

25-
err = yaml.Unmarshal(parameterFileContents, &parameterMap)
26-
25+
mapStringInterface := make(map[string]interface{})
26+
err = yaml.Unmarshal(parameterFileContents, &mapStringInterface)
2727
if err != nil {
2828
return nil, err
2929
}
3030

31+
parameterMap := map[string]string{}
32+
for k, v := range mapStringInterface {
33+
switch val := v.(type) {
34+
case string, bool, int:
35+
parameterMap[k] = fmt.Sprintf("%v", val)
36+
case []interface{}:
37+
b, err := json.Marshal(&val)
38+
if err != nil {
39+
return nil, err
40+
}
41+
parameterMap[k] = string(b)
42+
default:
43+
return nil, xerrors.Errorf("invalid parameter type: %T", v)
44+
}
45+
}
3146
return parameterMap, nil
3247
}
3348

cli/parameter_internal_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func TestCreateParameterMapFromFile(t *testing.T) {
6060
parameterMapFromFile, err := createParameterMapFromFile(parameterFile.Name())
6161

6262
assert.Nil(t, parameterMapFromFile)
63-
assert.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `region ...` into map[string]string")
63+
assert.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `region ...` into map[string]interface {}")
6464

6565
removeTmpDirUntilSuccess(t, tempDir)
6666
})

0 commit comments

Comments
 (0)