- Notifications
You must be signed in to change notification settings - Fork923
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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
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 | ||
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, | ||
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 | ||
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: | ||
maxIndex := m.getMaxIndex() | ||
if m.cursor > 0 { | ||
m.cursor-- | ||
} else { | ||
m.cursor =maxIndex | ||
Comment on lines -420 to +434 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 this logic needs to handle when | ||
} | ||
case tea.KeyDown: | ||
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 opt.option == m.customInput { | ||
// 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(), | ||
)) | ||
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 in63572d9
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) | ||||
gofunc() { | ||||
resp,err:=newMultiSelectWithCustomInput(ptty,items) | ||||
assert.NoError(t,err) | ||||
msgChan<-resp | ||||
}() | ||||
require.Equal(t,items,<-msgChan) | ||||
}) | ||||
} | ||||
funcnewMultiSelectWithCustomInput(ptty*ptytest.PTY,items []string) ([]string,error) { | ||||
varvalues []string | ||||
cmd:=&serpent.Command{ | ||||
Handler:func(inv*serpent.Invocation)error { | ||||
selectedItems,err:=cliui.MultiSelect(inv, cliui.MultiSelectOptions{ | ||||
Options:items, | ||||
Defaults:items, | ||||
EnableCustomInput:true, | ||||
}) | ||||
iferr==nil { | ||||
values=selectedItems | ||||
} | ||||
returnerr | ||||
}, | ||||
} | ||||
inv:=cmd.Invoke() | ||||
ptty.Attach(inv) | ||||
returnvalues,inv.Run() | ||||
} | ||||
funcnewMultiSelect(ptty*ptytest.PTY,items []string) ([]string,error) { | ||||
Uh oh!
There was an error while loading.Please reload this page.