-
Notifications
You must be signed in to change notification settings - Fork 875
feat: allow entering non-default values in multi-select #15935
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -300,9 +300,10 @@ func (m selectModel) filteredOptions() []string { | |
} | ||
|
||
type MultiSelectOptions struct { | ||
Message string | ||
Options []string | ||
Defaults []string | ||
Message string | ||
Options []string | ||
Defaults []string | ||
EnableCustomInput bool | ||
} | ||
|
||
func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) { | ||
|
@@ -328,9 +329,10 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er | |
} | ||
|
||
initialModel := multiSelectModel{ | ||
search: textinput.New(), | ||
options: options, | ||
message: opts.Message, | ||
search: textinput.New(), | ||
options: options, | ||
message: opts.Message, | ||
enableCustomInput: opts.EnableCustomInput, | ||
} | ||
|
||
initialModel.search.Prompt = "" | ||
|
@@ -370,12 +372,15 @@ type multiSelectOption struct { | |
} | ||
|
||
type multiSelectModel struct { | ||
search textinput.Model | ||
options []*multiSelectOption | ||
cursor int | ||
message string | ||
canceled bool | ||
selected bool | ||
search textinput.Model | ||
options []*multiSelectOption | ||
cursor int | ||
message string | ||
canceled bool | ||
selected bool | ||
isCustomInputMode bool // track if we're adding a custom option | ||
customInput string // store custom input | ||
enableCustomInput bool // control whether custom input is allowed | ||
} | ||
|
||
func (multiSelectModel) Init() tea.Cmd { | ||
|
@@ -386,6 +391,10 @@ func (multiSelectModel) Init() tea.Cmd { | |
func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
var cmd tea.Cmd | ||
|
||
if m.isCustomInputMode { | ||
return m.handleCustomInputMode(msg) | ||
} | ||
|
||
switch msg := msg.(type) { | ||
case terminateMsg: | ||
m.canceled = true | ||
|
@@ -398,6 +407,11 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
return m, tea.Quit | ||
|
||
case tea.KeyEnter: | ||
// Switch to custom input mode if we're on the "+ Add custom value:" option | ||
if m.enableCustomInput && m.cursor == len(m.filteredOptions()) { | ||
m.isCustomInputMode = true | ||
return m, nil | ||
} | ||
if len(m.options) != 0 { | ||
m.selected = true | ||
return m, tea.Quit | ||
|
@@ -413,16 +427,16 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
return m, nil | ||
|
||
case tea.KeyUp: | ||
options := m.filteredOptions() | ||
maxIndex := m.getMaxIndex() | ||
if m.cursor > 0 { | ||
m.cursor-- | ||
} else { | ||
m.cursor = len(options) - 1 | ||
m.cursor = maxIndex | ||
} | ||
|
||
case tea.KeyDown: | ||
options := m.filteredOptions() | ||
if m.cursor < len(options)-1 { | ||
maxIndex := m.getMaxIndex() | ||
if m.cursor < maxIndex { | ||
m.cursor++ | ||
} else { | ||
m.cursor = 0 | ||
|
@@ -457,6 +471,91 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
return m, cmd | ||
} | ||
|
||
func (m multiSelectModel) getMaxIndex() int { | ||
options := m.filteredOptions() | ||
if m.enableCustomInput { | ||
// Include the "+ Add custom value" entry | ||
return len(options) | ||
} | ||
// Includes only the actual options | ||
return len(options) - 1 | ||
} | ||
|
||
// handleCustomInputMode manages keyboard interactions when in custom input mode | ||
func (m *multiSelectModel) handleCustomInputMode(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
keyMsg, ok := msg.(tea.KeyMsg) | ||
if !ok { | ||
return m, nil | ||
} | ||
|
||
switch keyMsg.Type { | ||
case tea.KeyEnter: | ||
return m.handleCustomInputSubmission() | ||
|
||
case tea.KeyCtrlC: | ||
m.canceled = true | ||
return m, tea.Quit | ||
|
||
case tea.KeyBackspace: | ||
return m.handleCustomInputBackspace() | ||
|
||
default: | ||
m.customInput += keyMsg.String() | ||
return m, nil | ||
} | ||
} | ||
|
||
// handleCustomInputSubmission processes the submission of custom input | ||
func (m *multiSelectModel) handleCustomInputSubmission() (tea.Model, tea.Cmd) { | ||
if m.customInput == "" { | ||
m.isCustomInputMode = false | ||
return m, nil | ||
} | ||
|
||
// Clear search to ensure option is visible and cursor points to the new option | ||
m.search.SetValue("") | ||
|
||
// Check for duplicates | ||
for i, opt := range m.options { | ||
if strings.EqualFold(opt.option, m.customInput) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not 100% sure about the case-insensitive matching. On the surface, it seems innocuous enough but I'm not sure if there's going to be a case where someone is going to want to differentiate There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. updated the PR! |
||
// If the option exists but isn't chosen, select it | ||
if !opt.chosen { | ||
opt.chosen = true | ||
} | ||
|
||
// Point cursor to the new option | ||
m.cursor = i | ||
|
||
// Reset custom input mode to disabled | ||
m.isCustomInputMode = false | ||
m.customInput = "" | ||
return m, nil | ||
} | ||
} | ||
|
||
// Add new unique option | ||
m.options = append(m.options, &multiSelectOption{ | ||
option: m.customInput, | ||
chosen: true, | ||
}) | ||
|
||
// Point cursor to the newly added option | ||
m.cursor = len(m.options) - 1 | ||
|
||
// Reset custom input mode to disabled | ||
m.customInput = "" | ||
m.isCustomInputMode = false | ||
return m, nil | ||
} | ||
|
||
// handleCustomInputBackspace handles backspace in custom input mode | ||
func (m *multiSelectModel) handleCustomInputBackspace() (tea.Model, tea.Cmd) { | ||
if len(m.customInput) > 0 { | ||
m.customInput = m.customInput[:len(m.customInput)-1] | ||
} | ||
return m, nil | ||
} | ||
|
||
func (m multiSelectModel) View() string { | ||
var s strings.Builder | ||
|
||
|
@@ -469,13 +568,19 @@ func (m multiSelectModel) View() string { | |
return s.String() | ||
} | ||
|
||
if m.isCustomInputMode { | ||
_, _ = s.WriteString(fmt.Sprintf("%s\nEnter custom value: %s\n", msg, m.customInput)) | ||
return s.String() | ||
} | ||
|
||
_, _ = s.WriteString(fmt.Sprintf( | ||
"%s %s[Use arrows to move, space to select, <right> to all, <left> to none, type to filter]\n", | ||
msg, | ||
m.search.View(), | ||
)) | ||
|
||
for i, option := range m.filteredOptions() { | ||
options := m.filteredOptions() | ||
for i, option := range options { | ||
cursor := " " | ||
chosen := "[ ]" | ||
o := option.option | ||
|
@@ -498,6 +603,16 @@ func (m multiSelectModel) View() string { | |
)) | ||
} | ||
|
||
if m.enableCustomInput { | ||
// Add the "+ Add custom value" option at the bottom | ||
cursor := " " | ||
text := " + Add custom value" | ||
if m.cursor == len(options) { | ||
cursor = pretty.Sprint(DefaultStyles.Keyword, "> ") | ||
text = pretty.Sprint(DefaultStyles.Keyword, text) | ||
} | ||
_, _ = s.WriteString(fmt.Sprintf("%s%s\n", cursor, text)) | ||
} | ||
return s.String() | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -101,6 +101,39 @@ func TestMultiSelect(t *testing.T) { | |||
}() | ||||
require.Equal(t, items, <-msgChan) | ||||
}) | ||||
|
||||
t.Run("MultiSelectWithCustomInput", func(t *testing.T) { | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. couldn't add tests to check the custom input flow, we had this check here and not sure if its safe to remove and add interactive tests, please do let me know if there's a better way to incorporate those tests Line 310 in 63572d9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think historically we've manually tested this using |
||||
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) | ||||
}) | ||||
} | ||||
|
||||
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 | ||||
} | ||||
return err | ||||
}, | ||||
} | ||||
inv := cmd.Invoke() | ||||
ptty.Attach(inv) | ||||
return values, inv.Run() | ||||
} | ||||
|
||||
func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) { | ||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this logic needs to handle when
EnableCustomInput
is false. Currently it lets you go over the last valid option.