diff --git a/cli/cliui/parameter.go b/cli/cliui/parameter.go index 2e639f8dfa425..d972e346bf196 100644 --- a/cli/cliui/parameter.go +++ b/cli/cliui/parameter.go @@ -38,15 +38,16 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te // Move the cursor up a single line for nicer display! _, _ = fmt.Fprint(inv.Stdout, "\033[1A") - var options []string - err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &options) + var defaults []string + err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &defaults) if err != nil { return "", err } - values, err := MultiSelect(inv, MultiSelectOptions{ - Options: options, - Defaults: options, + values, err := RichMultiSelect(inv, RichMultiSelectOptions{ + Options: templateVersionParameter.Options, + Defaults: defaults, + EnableCustomInput: templateVersionParameter.FormType == "tag-select", }) if err == nil { v, err := json.Marshal(&values) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 40f63d92e279d..b3222cbbf3a71 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "slices" "strings" "syscall" @@ -299,6 +300,73 @@ func (m selectModel) filteredOptions() []string { return options } +type RichMultiSelectOptions struct { + Message string + Options []codersdk.TemplateVersionParameterOption + Defaults []string + EnableCustomInput bool +} + +func RichMultiSelect(inv *serpent.Invocation, richOptions RichMultiSelectOptions) ([]string, error) { + var opts []string + var defaultOpts []string + + asLine := func(option codersdk.TemplateVersionParameterOption) string { + line := option.Name + if len(option.Description) > 0 { + line += ": " + option.Description + } + return line + } + + var predefinedOpts []string + for i, option := range richOptions.Options { + opts = append(opts, asLine(option)) // Some options may have description defined. + + // Check if option is selected by default + if slices.Contains(richOptions.Defaults, option.Value) { + defaultOpts = append(defaultOpts, opts[i]) + predefinedOpts = append(predefinedOpts, option.Value) + } + } + + // Check if "defaults" contains extra/custom options, user could select them. + for _, def := range richOptions.Defaults { + if !slices.Contains(predefinedOpts, def) { + opts = append(opts, def) + defaultOpts = append(defaultOpts, def) + } + } + + selected, err := MultiSelect(inv, MultiSelectOptions{ + Message: richOptions.Message, + Options: opts, + Defaults: defaultOpts, + EnableCustomInput: richOptions.EnableCustomInput, + }) + if err != nil { + return nil, err + } + + // Check selected option, convert descriptions (line) to values + var results []string + for _, sel := range selected { + custom := true + for i, option := range richOptions.Options { + if asLine(option) == sel { + results = append(results, richOptions.Options[i].Value) + custom = false + break + } + } + + if custom { + results = append(results, sel) + } + } + return results, nil +} + type MultiSelectOptions struct { Message string Options []string diff --git a/cli/cliui/select_test.go b/cli/cliui/select_test.go index c7630ac4f2460..21fc4cb03c398 100644 --- a/cli/cliui/select_test.go +++ b/cli/cliui/select_test.go @@ -52,15 +52,8 @@ func TestRichSelect(t *testing.T) { go func() { resp, err := newRichSelect(ptty, cliui.RichSelectOptions{ Options: []codersdk.TemplateVersionParameterOption{ - { - Name: "A-Name", - Value: "A-Value", - Description: "A-Description.", - }, { - Name: "B-Name", - Value: "B-Value", - Description: "B-Description.", - }, + {Name: "A-Name", Value: "A-Value", Description: "A-Description."}, + {Name: "B-Name", Value: "B-Value", Description: "B-Description."}, }, }) assert.NoError(t, err) @@ -86,63 +79,119 @@ func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, err return value, inv.Run() } -func TestMultiSelect(t *testing.T) { +func TestRichMultiSelect(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) - }) + tests := []struct { + name string + options []codersdk.TemplateVersionParameterOption + defaults []string + allowCustom bool + want []string + }{ + { + name: "Predefined", + options: []codersdk.TemplateVersionParameterOption{ + {Name: "AAA", Description: "This is AAA", Value: "aaa"}, + {Name: "BBB", Description: "This is BBB", Value: "bbb"}, + {Name: "CCC", Description: "This is CCC", Value: "ccc"}, + }, + defaults: []string{"bbb", "ccc"}, + allowCustom: false, + want: []string{"bbb", "ccc"}, + }, + { + name: "Custom", + options: []codersdk.TemplateVersionParameterOption{ + {Name: "AAA", Description: "This is AAA", Value: "aaa"}, + {Name: "BBB", Description: "This is BBB", Value: "bbb"}, + {Name: "CCC", Description: "This is CCC", Value: "ccc"}, + }, + defaults: []string{"aaa", "bbb"}, + allowCustom: true, + want: []string{"aaa", "bbb"}, + }, + } - t.Run("MultiSelectWithCustomInput", func(t *testing.T) { - t.Parallel() - items := []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"} - ptty := ptytest.New(t) - msgChan := make(chan []string) - go func() { - resp, err := newMultiSelectWithCustomInput(ptty, items) - assert.NoError(t, err) - msgChan <- resp - }() - require.Equal(t, items, <-msgChan) - }) -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() -func newMultiSelectWithCustomInput(ptty *ptytest.PTY, items []string) ([]string, error) { - var values []string - cmd := &serpent.Command{ - Handler: func(inv *serpent.Invocation) error { - selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ - Options: items, - Defaults: items, - EnableCustomInput: true, - }) - if err == nil { - values = selectedItems + var selectedItems []string + var err error + cmd := &serpent.Command{ + Handler: func(inv *serpent.Invocation) error { + selectedItems, err = cliui.RichMultiSelect(inv, cliui.RichMultiSelectOptions{ + Options: tt.options, + Defaults: tt.defaults, + EnableCustomInput: tt.allowCustom, + }) + return err + }, } - return err + + doneChan := make(chan struct{}) + go func() { + defer close(doneChan) + err := cmd.Invoke().Run() + assert.NoError(t, err) + }() + <-doneChan + + require.Equal(t, tt.want, selectedItems) + }) + } +} + +func TestMultiSelect(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + items []string + allowCustom bool + want []string + }{ + { + name: "MultiSelect", + items: []string{"aaa", "bbb", "ccc"}, + allowCustom: false, + want: []string{"aaa", "bbb", "ccc"}, + }, + { + name: "MultiSelectWithCustomInput", + items: []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"}, + allowCustom: true, + want: []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"}, }, } - inv := cmd.Invoke() - ptty.Attach(inv) - return values, inv.Run() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ptty := ptytest.New(t) + msgChan := make(chan []string) + + go func() { + resp, err := newMultiSelect(ptty, tt.items, tt.allowCustom) + assert.NoError(t, err) + msgChan <- resp + }() + + require.Equal(t, tt.want, <-msgChan) + }) + } } -func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) { +func newMultiSelect(pty *ptytest.PTY, items []string, custom bool) ([]string, error) { var values []string cmd := &serpent.Command{ Handler: func(inv *serpent.Invocation) error { selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ - Options: items, - Defaults: items, + Options: items, + Defaults: items, + EnableCustomInput: custom, }) if err == nil { values = selectedItems @@ -151,6 +200,6 @@ func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) { }, } inv := cmd.Invoke() - ptty.Attach(inv) + pty.Attach(inv) return values, inv.Run() } diff --git a/cli/exp_prompts.go b/cli/exp_prompts.go index 225685a0c375a..ef51a1ce04398 100644 --- a/cli/exp_prompts.go +++ b/cli/exp_prompts.go @@ -174,6 +174,20 @@ func (RootCmd) promptExample() *serpent.Command { _, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", ")) return multiSelectError }, useThingsOption, enableCustomInputOption), + promptCmd("rich-multi-select", func(inv *serpent.Invocation) error { + if len(multiSelectValues) == 0 { + multiSelectValues, multiSelectError = cliui.MultiSelect(inv, cliui.MultiSelectOptions{ + Message: "Select some things:", + Options: []string{ + "Apples", "Plums", "Grapes", "Oranges", "Bananas", + }, + Defaults: []string{"Grapes", "Plums"}, + EnableCustomInput: enableCustomInput, + }) + } + _, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", ")) + return multiSelectError + }, useThingsOption, enableCustomInputOption), promptCmd("rich-parameter", func(inv *serpent.Invocation) error { value, err := cliui.RichSelect(inv, cliui.RichSelectOptions{ Options: []codersdk.TemplateVersionParameterOption{