Skip to content

Commit a7fac30

Browse files
authored
feat: implement rich multi-selector (#19201)
Fixes: #19182
1 parent 9505ecc commit a7fac30

File tree

4 files changed

+191
-59
lines changed

4 files changed

+191
-59
lines changed

cli/cliui/parameter.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,16 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
3838
// Move the cursor up a single line for nicer display!
3939
_, _ = fmt.Fprint(inv.Stdout, "\033[1A")
4040

41-
var options []string
42-
err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &options)
41+
var defaults []string
42+
err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &defaults)
4343
if err != nil {
4444
return "", err
4545
}
4646

47-
values, err := MultiSelect(inv, MultiSelectOptions{
48-
Options: options,
49-
Defaults: options,
47+
values, err := RichMultiSelect(inv, RichMultiSelectOptions{
48+
Options: templateVersionParameter.Options,
49+
Defaults: defaults,
50+
EnableCustomInput: templateVersionParameter.FormType == "tag-select",
5051
})
5152
if err == nil {
5253
v, err := json.Marshal(&values)

cli/cliui/select.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"os/signal"
8+
"slices"
89
"strings"
910
"syscall"
1011

@@ -299,6 +300,73 @@ func (m selectModel) filteredOptions() []string {
299300
return options
300301
}
301302

303+
type RichMultiSelectOptions struct {
304+
Message string
305+
Options []codersdk.TemplateVersionParameterOption
306+
Defaults []string
307+
EnableCustomInput bool
308+
}
309+
310+
func RichMultiSelect(inv *serpent.Invocation, richOptions RichMultiSelectOptions) ([]string, error) {
311+
var opts []string
312+
var defaultOpts []string
313+
314+
asLine := func(option codersdk.TemplateVersionParameterOption) string {
315+
line := option.Name
316+
if len(option.Description) > 0 {
317+
line += ": " + option.Description
318+
}
319+
return line
320+
}
321+
322+
var predefinedOpts []string
323+
for i, option := range richOptions.Options {
324+
opts = append(opts, asLine(option)) // Some options may have description defined.
325+
326+
// Check if option is selected by default
327+
if slices.Contains(richOptions.Defaults, option.Value) {
328+
defaultOpts = append(defaultOpts, opts[i])
329+
predefinedOpts = append(predefinedOpts, option.Value)
330+
}
331+
}
332+
333+
// Check if "defaults" contains extra/custom options, user could select them.
334+
for _, def := range richOptions.Defaults {
335+
if !slices.Contains(predefinedOpts, def) {
336+
opts = append(opts, def)
337+
defaultOpts = append(defaultOpts, def)
338+
}
339+
}
340+
341+
selected, err := MultiSelect(inv, MultiSelectOptions{
342+
Message: richOptions.Message,
343+
Options: opts,
344+
Defaults: defaultOpts,
345+
EnableCustomInput: richOptions.EnableCustomInput,
346+
})
347+
if err != nil {
348+
return nil, err
349+
}
350+
351+
// Check selected option, convert descriptions (line) to values
352+
var results []string
353+
for _, sel := range selected {
354+
custom := true
355+
for i, option := range richOptions.Options {
356+
if asLine(option) == sel {
357+
results = append(results, richOptions.Options[i].Value)
358+
custom = false
359+
break
360+
}
361+
}
362+
363+
if custom {
364+
results = append(results, sel)
365+
}
366+
}
367+
return results, nil
368+
}
369+
302370
type MultiSelectOptions struct {
303371
Message string
304372
Options []string

cli/cliui/select_test.go

Lines changed: 103 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,8 @@ func TestRichSelect(t *testing.T) {
5252
go func() {
5353
resp, err := newRichSelect(ptty, cliui.RichSelectOptions{
5454
Options: []codersdk.TemplateVersionParameterOption{
55-
{
56-
Name: "A-Name",
57-
Value: "A-Value",
58-
Description: "A-Description.",
59-
}, {
60-
Name: "B-Name",
61-
Value: "B-Value",
62-
Description: "B-Description.",
63-
},
55+
{Name: "A-Name", Value: "A-Value", Description: "A-Description."},
56+
{Name: "B-Name", Value: "B-Value", Description: "B-Description."},
6457
},
6558
})
6659
assert.NoError(t, err)
@@ -86,63 +79,119 @@ func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, err
8679
return value, inv.Run()
8780
}
8881

89-
func TestMultiSelect(t *testing.T) {
82+
func TestRichMultiSelect(t *testing.T) {
9083
t.Parallel()
91-
t.Run("MultiSelect", func(t *testing.T) {
92-
items := []string{"aaa", "bbb", "ccc"}
9384

94-
t.Parallel()
95-
ptty := ptytest.New(t)
96-
msgChan := make(chan []string)
97-
go func() {
98-
resp, err := newMultiSelect(ptty, items)
99-
assert.NoError(t, err)
100-
msgChan <- resp
101-
}()
102-
require.Equal(t, items, <-msgChan)
103-
})
85+
tests := []struct {
86+
name string
87+
options []codersdk.TemplateVersionParameterOption
88+
defaults []string
89+
allowCustom bool
90+
want []string
91+
}{
92+
{
93+
name: "Predefined",
94+
options: []codersdk.TemplateVersionParameterOption{
95+
{Name: "AAA", Description: "This is AAA", Value: "aaa"},
96+
{Name: "BBB", Description: "This is BBB", Value: "bbb"},
97+
{Name: "CCC", Description: "This is CCC", Value: "ccc"},
98+
},
99+
defaults: []string{"bbb", "ccc"},
100+
allowCustom: false,
101+
want: []string{"bbb", "ccc"},
102+
},
103+
{
104+
name: "Custom",
105+
options: []codersdk.TemplateVersionParameterOption{
106+
{Name: "AAA", Description: "This is AAA", Value: "aaa"},
107+
{Name: "BBB", Description: "This is BBB", Value: "bbb"},
108+
{Name: "CCC", Description: "This is CCC", Value: "ccc"},
109+
},
110+
defaults: []string{"aaa", "bbb"},
111+
allowCustom: true,
112+
want: []string{"aaa", "bbb"},
113+
},
114+
}
104115

105-
t.Run("MultiSelectWithCustomInput", func(t *testing.T) {
106-
t.Parallel()
107-
items := []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"}
108-
ptty := ptytest.New(t)
109-
msgChan := make(chan []string)
110-
go func() {
111-
resp, err := newMultiSelectWithCustomInput(ptty, items)
112-
assert.NoError(t, err)
113-
msgChan <- resp
114-
}()
115-
require.Equal(t, items, <-msgChan)
116-
})
117-
}
116+
for _, tt := range tests {
117+
t.Run(tt.name, func(t *testing.T) {
118+
t.Parallel()
118119

119-
func newMultiSelectWithCustomInput(ptty *ptytest.PTY, items []string) ([]string, error) {
120-
var values []string
121-
cmd := &serpent.Command{
122-
Handler: func(inv *serpent.Invocation) error {
123-
selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
124-
Options: items,
125-
Defaults: items,
126-
EnableCustomInput: true,
127-
})
128-
if err == nil {
129-
values = selectedItems
120+
var selectedItems []string
121+
var err error
122+
cmd := &serpent.Command{
123+
Handler: func(inv *serpent.Invocation) error {
124+
selectedItems, err = cliui.RichMultiSelect(inv, cliui.RichMultiSelectOptions{
125+
Options: tt.options,
126+
Defaults: tt.defaults,
127+
EnableCustomInput: tt.allowCustom,
128+
})
129+
return err
130+
},
130131
}
131-
return err
132+
133+
doneChan := make(chan struct{})
134+
go func() {
135+
defer close(doneChan)
136+
err := cmd.Invoke().Run()
137+
assert.NoError(t, err)
138+
}()
139+
<-doneChan
140+
141+
require.Equal(t, tt.want, selectedItems)
142+
})
143+
}
144+
}
145+
146+
func TestMultiSelect(t *testing.T) {
147+
t.Parallel()
148+
149+
tests := []struct {
150+
name string
151+
items []string
152+
allowCustom bool
153+
want []string
154+
}{
155+
{
156+
name: "MultiSelect",
157+
items: []string{"aaa", "bbb", "ccc"},
158+
allowCustom: false,
159+
want: []string{"aaa", "bbb", "ccc"},
160+
},
161+
{
162+
name: "MultiSelectWithCustomInput",
163+
items: []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"},
164+
allowCustom: true,
165+
want: []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"},
132166
},
133167
}
134-
inv := cmd.Invoke()
135-
ptty.Attach(inv)
136-
return values, inv.Run()
168+
169+
for _, tt := range tests {
170+
t.Run(tt.name, func(t *testing.T) {
171+
t.Parallel()
172+
173+
ptty := ptytest.New(t)
174+
msgChan := make(chan []string)
175+
176+
go func() {
177+
resp, err := newMultiSelect(ptty, tt.items, tt.allowCustom)
178+
assert.NoError(t, err)
179+
msgChan <- resp
180+
}()
181+
182+
require.Equal(t, tt.want, <-msgChan)
183+
})
184+
}
137185
}
138186

139-
func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
187+
func newMultiSelect(pty *ptytest.PTY, items []string, custom bool) ([]string, error) {
140188
var values []string
141189
cmd := &serpent.Command{
142190
Handler: func(inv *serpent.Invocation) error {
143191
selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
144-
Options: items,
145-
Defaults: items,
192+
Options: items,
193+
Defaults: items,
194+
EnableCustomInput: custom,
146195
})
147196
if err == nil {
148197
values = selectedItems
@@ -151,6 +200,6 @@ func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
151200
},
152201
}
153202
inv := cmd.Invoke()
154-
ptty.Attach(inv)
203+
pty.Attach(inv)
155204
return values, inv.Run()
156205
}

cli/exp_prompts.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,20 @@ func (RootCmd) promptExample() *serpent.Command {
174174
_, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", "))
175175
return multiSelectError
176176
}, useThingsOption, enableCustomInputOption),
177+
promptCmd("rich-multi-select", func(inv *serpent.Invocation) error {
178+
if len(multiSelectValues) == 0 {
179+
multiSelectValues, multiSelectError = cliui.MultiSelect(inv, cliui.MultiSelectOptions{
180+
Message: "Select some things:",
181+
Options: []string{
182+
"Apples", "Plums", "Grapes", "Oranges", "Bananas",
183+
},
184+
Defaults: []string{"Grapes", "Plums"},
185+
EnableCustomInput: enableCustomInput,
186+
})
187+
}
188+
_, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", "))
189+
return multiSelectError
190+
}, useThingsOption, enableCustomInputOption),
177191
promptCmd("rich-parameter", func(inv *serpent.Invocation) error {
178192
value, err := cliui.RichSelect(inv, cliui.RichSelectOptions{
179193
Options: []codersdk.TemplateVersionParameterOption{

0 commit comments

Comments
 (0)