feat: implement rich multi-selector (#19201)

Fixes: https://github.com/coder/coder/issues/19182
This commit is contained in:
Marcin Tojek
2025-08-06 23:03:51 +02:00
committed by GitHub
parent 9505ecc73d
commit a7fac302bb
4 changed files with 193 additions and 61 deletions
+6 -5
View File
@@ -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)
+68
View File
@@ -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
+105 -56
View File
@@ -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,44 +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()
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
},
}
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 newMultiSelectWithCustomInput(ptty *ptytest.PTY, items []string) ([]string, error) {
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"},
},
}
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(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,
EnableCustomInput: true,
EnableCustomInput: custom,
})
if err == nil {
values = selectedItems
@@ -132,25 +200,6 @@ func newMultiSelectWithCustomInput(ptty *ptytest.PTY, items []string) ([]string,
},
}
inv := cmd.Invoke()
ptty.Attach(inv)
return values, inv.Run()
}
func newMultiSelect(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,
})
if err == nil {
values = selectedItems
}
return err
},
}
inv := cmd.Invoke()
ptty.Attach(inv)
pty.Attach(inv)
return values, inv.Run()
}
+14
View File
@@ -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{