MultiSelectWithSearch initial implementation

Initial implementation of MultiSelectWithSearch:

- Implement by survey and accessible prompters. They use the same internal func under the hood.
- Implement in `gh preview prompter` for initial testing and demonstration
- Implement interface changes across the codebase and mocks to satisfy compiler.
- Implement tests for new MultiSelectWithSearch prompter
This commit is contained in:
Kynan Ware 2025-11-22 15:01:55 -07:00
parent be1e21095c
commit 0beb74bf72
6 changed files with 478 additions and 31 deletions

View file

@ -224,6 +224,167 @@ func TestAccessiblePrompter(t *testing.T) {
assert.Equal(t, []int{1}, multiSelectValues)
})
t.Run("MultiSelectWithSearch - basic flow", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
persistentOptions := []string{"persistent-option-1"}
searchFunc := func(input string) ([]string, []string, int, error) {
var searchResultKeys []string
var searchResultLabels []string
// Initial search with no input
if input == "" {
moreResults := 2
searchResultKeys = []string{"initial-result-1", "initial-result-2"}
searchResultLabels = []string{"Initial Result Label 1", "Initial Result Label 2"}
return searchResultKeys, searchResultLabels, moreResults, nil
}
// Subsequent search with input
moreResults := 0
searchResultKeys = []string{"search-result-1", "search-result-2"}
searchResultLabels = []string{"Search Result Label 1", "Search Result Label 2"}
return searchResultKeys, searchResultLabels, moreResults, nil
}
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select an option \r\n")
require.NoError(t, err)
// Select the search option, which will always be the first option
_, err = console.SendLine("1")
require.NoError(t, err)
// Submit search
_, err = console.SendLine("0")
require.NoError(t, err)
// Wait for the search prompt to appear
_, err = console.ExpectString("Search for an option")
require.NoError(t, err)
// Enter some search text to trigger the search
_, err = console.SendLine("search text")
require.NoError(t, err)
// Wait for the multiselect prompt to re-appear after search
_, err = console.ExpectString("Select an option \r\n")
require.NoError(t, err)
// Select the first search result
_, err = console.SendLine("2")
require.NoError(t, err)
// This confirms selections
_, err = console.SendLine("0")
require.NoError(t, err)
}()
multiSelectValues, err := p.MultiSelectWithSearch("Select an option", "Search for an option", []string{}, persistentOptions, searchFunc)
require.NoError(t, err)
assert.Equal(t, []string{"search-result-1"}, multiSelectValues)
})
t.Run("MultiSelectWithSearch - defaults are pre-selected", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
initialSearchResultKeys := []string{"initial-result-1"}
initialSearchResultLabels := []string{"Initial Result Label 1"}
defaultOptions := initialSearchResultKeys
searchFunc := func(input string) ([]string, []string, int, error) {
// Initial search with no input
if input == "" {
moreResults := 2
return initialSearchResultKeys, initialSearchResultLabels, moreResults, nil
}
// No search selected, so this should fail the test.
t.FailNow()
return nil, nil, 0, nil
}
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select an option (default: Initial Result Label 1) \r\n")
require.NoError(t, err)
// This confirms default selections
_, err = console.SendLine("0")
require.NoError(t, err)
}()
multiSelectValues, err := p.MultiSelectWithSearch("Select an option", "Search for an option", defaultOptions, initialSearchResultKeys, searchFunc)
require.NoError(t, err)
assert.Equal(t, defaultOptions, multiSelectValues)
})
t.Run("MultiSelectWithSearch - selected options persist between searches", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
initialSearchResultKeys := []string{"initial-result-1"}
initialSearchResultLabels := []string{"Initial Result Label 1"}
moreResultKeys := []string{"more-result-1"}
moreResultLabels := []string{"More Result Label 1"}
searchFunc := func(input string) ([]string, []string, int, error) {
// Initial search with no input
if input == "" {
moreResults := 2
return initialSearchResultKeys, initialSearchResultLabels, moreResults, nil
}
// Subsequent search with input "more"
if input == "more" {
return moreResultKeys, moreResultLabels, 0, nil
}
// No other searches expected
t.FailNow()
return nil, nil, 0, nil
}
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select an option \r\n")
require.NoError(t, err)
// Select one of our initial search results
_, err = console.SendLine("2")
require.NoError(t, err)
// Select to search
_, err = console.SendLine("1")
require.NoError(t, err)
// Submit the search selection
_, err = console.SendLine("0")
require.NoError(t, err)
// Wait for the search prompt to appear
_, err = console.ExpectString("Search for an option")
require.NoError(t, err)
// Enter some search text to trigger the search
_, err = console.SendLine("more")
require.NoError(t, err)
// Wait for the multiselect prompt to re-appear after search
_, err = console.ExpectString("Select up to")
require.NoError(t, err)
// Select the new option from the new search results
_, err = console.SendLine("3")
require.NoError(t, err)
// Submit selections
_, err = console.SendLine("0")
require.NoError(t, err)
}()
multiSelectValues, err := p.MultiSelectWithSearch("Select an option", "Search for an option", []string{}, []string{}, searchFunc)
require.NoError(t, err)
expectedValues := append(initialSearchResultKeys, moreResultKeys...)
assert.Equal(t, expectedValues, multiSelectValues)
})
t.Run("Input", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
@ -642,6 +803,7 @@ func newTestVirtualTerminal(t *testing.T) *expect.Console {
failOnExpectError(t),
failOnSendError(t),
expect.WithDefaultTimeout(time.Second),
// expect.WithLogger(log.New(os.Stdout, "", 0)),
}
console, err := expect.NewConsole(consoleOpts...)

View file

@ -21,6 +21,15 @@ type Prompter interface {
Select(prompt string, defaultValue string, options []string) (int, error)
// MultiSelect prompts the user to select one or more options from a list of options.
MultiSelect(prompt string, defaults []string, options []string) ([]int, error)
// MultiSelectWithSearch is MultiSelect with an added search option to the list,
// prompting the user for text input to filter the options via the searchFunc.
// Items selected in the search are persisted in the list after subsequent searches.
// Items passed in persistentOptions are always shown in the list, even when not selected.
// Unlike MultiSelect, MultiselectWithSearch returns the selected option strings,
// not their indices, since the list of options is dynamic.
// The searchFunc args and return values are: func(query) (map[keys]labels, moreResultsCount, searchError)
// Where the selected keys are eventually returned by MultiSelectWithSearch and the labels are what is shown to the user in the prompt.
MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error)
// Input prompts the user to enter a string value.
Input(prompt string, defaultValue string) (string, error)
// Password prompts the user to enter a password.
@ -320,6 +329,10 @@ func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAl
return text, nil
}
func (p *accessiblePrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) {
return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
}
type surveyPrompter struct {
prompter *ghPrompter.Prompter
stdin ghPrompter.FileReader
@ -336,6 +349,147 @@ func (p *surveyPrompter) MultiSelect(prompt string, defaultValues, options []str
return p.prompter.MultiSelect(prompt, defaultValues, options)
}
func (p *surveyPrompter) MultiSelectWithSearch(prompt string, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) {
return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
}
func multiSelectWithSearch(p Prompter, prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) {
selectedOptions := defaultValues
// The optionKeyLabels map is used to uniquely identify optionKeyLabels
// and provide optional display labels.
optionKeyLabels := make(map[string]string)
for _, k := range selectedOptions {
optionKeyLabels[k] = k
}
searchResultKeys, searchResultLabels, moreResults, err := searchFunc("")
if err != nil {
return nil, fmt.Errorf("failed to search: %w", err)
}
for i, k := range searchResultKeys {
optionKeyLabels[k] = searchResultLabels[i]
}
for {
// Build dynamic option list -> search sentinel, selections, search results, persistent options.
optionKeys := make([]string, 0, 1+len(selectedOptions)+len(searchResultKeys)+len(persistentValues))
optionLabels := make([]string, 0, len(optionKeys))
// 1. Search sentinel.
optionKeys = append(optionKeys, "")
if moreResults > 0 {
optionLabels = append(optionLabels, fmt.Sprintf("Search (%d more)", moreResults))
} else {
optionLabels = append(optionLabels, "Search")
}
// 2. Selections
for _, k := range selectedOptions {
l := optionKeyLabels[k]
if l == "" {
l = k
}
optionKeys = append(optionKeys, k)
optionLabels = append(optionLabels, l)
}
// 3. Search results
for _, k := range searchResultKeys {
// It's already selected or persistent, if we add here we'll have duplicates.
if slices.Contains(selectedOptions, k) || slices.Contains(persistentValues, k) {
continue
}
l := optionKeyLabels[k]
if l == "" {
l = k
}
optionKeys = append(optionKeys, k)
optionLabels = append(optionLabels, l)
}
// 4. Persistent options
for _, k := range persistentValues {
if slices.Contains(selectedOptions, k) {
continue
}
l := optionKeyLabels[k]
if l == "" {
l = k
}
optionKeys = append(optionKeys, k)
optionLabels = append(optionLabels, l)
}
selectedOptionLabels := make([]string, len(selectedOptions))
for i, k := range selectedOptions {
l := optionKeyLabels[k]
if l == "" {
l = k
}
selectedOptionLabels[i] = l
}
selectedIdxs, err := p.MultiSelect(prompt, selectedOptionLabels, optionLabels)
if err != nil {
return nil, err
}
pickedSearch := false
var newSelectedOptions []string
for _, idx := range selectedIdxs {
if idx == 0 { // Search sentinel selected
pickedSearch = true
continue
}
if idx < 0 || idx >= len(optionKeys) {
continue
}
key := optionKeys[idx]
if key == "" {
continue
}
newSelectedOptions = append(newSelectedOptions, key)
}
selectedOptions = newSelectedOptions
for _, k := range selectedOptions {
if _, ok := optionKeyLabels[k]; !ok {
optionKeyLabels[k] = k
}
}
if pickedSearch {
query, err := p.Input(searchPrompt, "")
if err != nil {
return nil, err
}
searchResultKeys, searchResultLabels, moreResults, err = searchFunc(query)
if err != nil {
return nil, err
}
for i, k := range searchResultKeys {
optionKeyLabels[k] = searchResultLabels[i]
}
continue
}
return selectedOptions, nil
}
}
func (p *surveyPrompter) Input(prompt, defaultValue string) (string, error) {
return p.prompter.Input(prompt, defaultValue)
}

View file

@ -38,6 +38,9 @@ var _ Prompter = &PrompterMock{}
// MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) {
// panic("mock out the MultiSelect method")
// },
// MultiSelectWithSearchFunc: func(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) {
// panic("mock out the MultiSelectWithSearch method")
// },
// PasswordFunc: func(prompt string) (string, error) {
// panic("mock out the Password method")
// },
@ -72,6 +75,9 @@ type PrompterMock struct {
// MultiSelectFunc mocks the MultiSelect method.
MultiSelectFunc func(prompt string, defaults []string, options []string) ([]int, error)
// MultiSelectWithSearchFunc mocks the MultiSelectWithSearch method.
MultiSelectWithSearchFunc func(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error)
// PasswordFunc mocks the Password method.
PasswordFunc func(prompt string) (string, error)
@ -123,6 +129,19 @@ type PrompterMock struct {
// Options is the options argument value.
Options []string
}
// MultiSelectWithSearch holds details about calls to the MultiSelectWithSearch method.
MultiSelectWithSearch []struct {
// Prompt is the prompt argument value.
Prompt string
// SearchPrompt is the searchPrompt argument value.
SearchPrompt string
// Defaults is the defaults argument value.
Defaults []string
// PersistentOptions is the persistentOptions argument value.
PersistentOptions []string
// SearchFunc is the searchFunc argument value.
SearchFunc func(string) ([]string, []string, int, error)
}
// Password holds details about calls to the Password method.
Password []struct {
// Prompt is the prompt argument value.
@ -138,15 +157,16 @@ type PrompterMock struct {
Options []string
}
}
lockAuthToken sync.RWMutex
lockConfirm sync.RWMutex
lockConfirmDeletion sync.RWMutex
lockInput sync.RWMutex
lockInputHostname sync.RWMutex
lockMarkdownEditor sync.RWMutex
lockMultiSelect sync.RWMutex
lockPassword sync.RWMutex
lockSelect sync.RWMutex
lockAuthToken sync.RWMutex
lockConfirm sync.RWMutex
lockConfirmDeletion sync.RWMutex
lockInput sync.RWMutex
lockInputHostname sync.RWMutex
lockMarkdownEditor sync.RWMutex
lockMultiSelect sync.RWMutex
lockMultiSelectWithSearch sync.RWMutex
lockPassword sync.RWMutex
lockSelect sync.RWMutex
}
// AuthToken calls AuthTokenFunc.
@ -387,6 +407,54 @@ func (mock *PrompterMock) MultiSelectCalls() []struct {
return calls
}
// MultiSelectWithSearch calls MultiSelectWithSearchFunc.
func (mock *PrompterMock) MultiSelectWithSearch(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) {
if mock.MultiSelectWithSearchFunc == nil {
panic("PrompterMock.MultiSelectWithSearchFunc: method is nil but Prompter.MultiSelectWithSearch was just called")
}
callInfo := struct {
Prompt string
SearchPrompt string
Defaults []string
PersistentOptions []string
SearchFunc func(string) ([]string, []string, int, error)
}{
Prompt: prompt,
SearchPrompt: searchPrompt,
Defaults: defaults,
PersistentOptions: persistentOptions,
SearchFunc: searchFunc,
}
mock.lockMultiSelectWithSearch.Lock()
mock.calls.MultiSelectWithSearch = append(mock.calls.MultiSelectWithSearch, callInfo)
mock.lockMultiSelectWithSearch.Unlock()
return mock.MultiSelectWithSearchFunc(prompt, searchPrompt, defaults, persistentOptions, searchFunc)
}
// MultiSelectWithSearchCalls gets all the calls that were made to MultiSelectWithSearch.
// Check the length with:
//
// len(mockedPrompter.MultiSelectWithSearchCalls())
func (mock *PrompterMock) MultiSelectWithSearchCalls() []struct {
Prompt string
SearchPrompt string
Defaults []string
PersistentOptions []string
SearchFunc func(string) ([]string, []string, int, error)
} {
var calls []struct {
Prompt string
SearchPrompt string
Defaults []string
PersistentOptions []string
SearchFunc func(string) ([]string, []string, int, error)
}
mock.lockMultiSelectWithSearch.RLock()
calls = mock.calls.MultiSelectWithSearch
mock.lockMultiSelectWithSearch.RUnlock()
return calls
}
// Password calls PasswordFunc.
func (mock *PrompterMock) Password(prompt string) (string, error) {
if mock.PasswordFunc == nil {

View file

@ -25,10 +25,11 @@ func NewMockPrompter(t *testing.T) *MockPrompter {
type MockPrompter struct {
t *testing.T
ghPrompter.PrompterMock
authTokenStubs []authTokenStub
confirmDeletionStubs []confirmDeletionStub
inputHostnameStubs []inputHostnameStub
markdownEditorStubs []markdownEditorStub
authTokenStubs []authTokenStub
confirmDeletionStubs []confirmDeletionStub
inputHostnameStubs []inputHostnameStub
markdownEditorStubs []markdownEditorStub
multiSelectWithSearchStubs []multiSelectWithSearchStub
}
type authTokenStub struct {
@ -49,6 +50,12 @@ type markdownEditorStub struct {
fn func(string, string, bool) (string, error)
}
type multiSelectWithSearchStub struct {
prompt string
searchPrompt string
fn func(string, string, []string, []string) ([]string, error)
}
func (m *MockPrompter) AuthToken() (string, error) {
var s authTokenStub
if len(m.authTokenStubs) == 0 {
@ -92,6 +99,16 @@ func (m *MockPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed
return s.fn(prompt, defaultValue, blankAllowed)
}
func (m *MockPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) {
var s multiSelectWithSearchStub
if len(m.multiSelectWithSearchStubs) == 0 {
return nil, NoSuchPromptErr(prompt)
}
s = m.multiSelectWithSearchStubs[0]
m.multiSelectWithSearchStubs = m.multiSelectWithSearchStubs[1:len(m.multiSelectWithSearchStubs)]
return s.fn(prompt, searchPrompt, defaults, persistentOptions)
}
func (m *MockPrompter) RegisterAuthToken(stub func() (string, error)) {
m.authTokenStubs = append(m.authTokenStubs, authTokenStub{fn: stub})
}

View file

@ -40,6 +40,7 @@ type Prompt interface {
MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error)
Confirm(prompt string, defaultValue bool) (bool, error)
MultiSelect(prompt string, defaults []string, options []string) ([]int, error)
MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error)
}
func ConfirmIssueSubmission(p Prompt, allowPreview bool, allowMetadata bool) (Action, error) {
@ -207,6 +208,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
// Populate the list of selectable assignees and their default selections.
// This logic maps the default assignees from `state` to the corresponding actors or users
// so that the correct display names are preselected in the prompt.
// TODO: KW21 This will need to go away since we're going to dynamically load assignees via search.
var assignees []string
var assigneesDefault []string
if state.ActorAssignees {
@ -264,6 +266,9 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
fmt.Fprintln(io.ErrOut, "warning: no available reviewers")
}
}
// TODO: KW21 This will need to change to use MultiSelectWithSearch once it's implemented.
// MultiSelectWithSearch will return the selected strings directly instead of indices,
// so the logic here will need to be updated accordingly.
if isChosen("Assignees") {
if len(assignees) > 0 {
selected, err := p.MultiSelect("Assignees", assigneesDefault, assignees)

View file

@ -2,6 +2,7 @@ package prompter
import (
"fmt"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/gh"
@ -25,32 +26,35 @@ func NewCmdPrompter(f *cmdutil.Factory, runF func(*prompterOptions) error) *cobr
}
const (
selectPrompt = "select"
multiSelectPrompt = "multi-select"
inputPrompt = "input"
passwordPrompt = "password"
confirmPrompt = "confirm"
authTokenPrompt = "auth-token"
confirmDeletionPrompt = "confirm-deletion"
inputHostnamePrompt = "input-hostname"
markdownEditorPrompt = "markdown-editor"
selectPrompt = "select"
multiSelectPrompt = "multi-select"
multiSelectWithSearchPrompt = "multi-select-with-search"
inputPrompt = "input"
passwordPrompt = "password"
confirmPrompt = "confirm"
authTokenPrompt = "auth-token"
confirmDeletionPrompt = "confirm-deletion"
inputHostnamePrompt = "input-hostname"
markdownEditorPrompt = "markdown-editor"
)
prompterTypeFuncMap := map[string]func(prompter.Prompter, *iostreams.IOStreams) error{
selectPrompt: runSelect,
multiSelectPrompt: runMultiSelect,
inputPrompt: runInput,
passwordPrompt: runPassword,
confirmPrompt: runConfirm,
authTokenPrompt: runAuthToken,
confirmDeletionPrompt: runConfirmDeletion,
inputHostnamePrompt: runInputHostname,
markdownEditorPrompt: runMarkdownEditor,
selectPrompt: runSelect,
multiSelectPrompt: runMultiSelect,
multiSelectWithSearchPrompt: runMultiSelectWithSearch,
inputPrompt: runInput,
passwordPrompt: runPassword,
confirmPrompt: runConfirm,
authTokenPrompt: runAuthToken,
confirmDeletionPrompt: runConfirmDeletion,
inputHostnamePrompt: runInputHostname,
markdownEditorPrompt: runMarkdownEditor,
}
allPromptsOrder := []string{
selectPrompt,
multiSelectPrompt,
multiSelectWithSearchPrompt,
inputPrompt,
passwordPrompt,
confirmPrompt,
@ -70,6 +74,7 @@ func NewCmdPrompter(f *cmdutil.Factory, runF func(*prompterOptions) error) *cobr
Available prompt types:
- select
- multi-select
- multi-select-with-search
- input
- password
- confirm
@ -149,6 +154,42 @@ func runMultiSelect(p prompter.Prompter, io *iostreams.IOStreams) error {
return nil
}
func runMultiSelectWithSearch(p prompter.Prompter, io *iostreams.IOStreams) error {
fmt.Fprintln(io.Out, "Demonstrating Multi Select With Search")
persistentOptions := []string{"persistent-option-1"}
searchFunc := func(input string) ([]string, []string, int, error) {
var searchResultKeys []string
var searchResultLabels []string
if input == "" {
moreResults := 2 // Indicate that there are more results available
searchResultKeys = []string{"initial-result-1", "initial-result-2"}
searchResultLabels = []string{"Initial Result Label 1", "Initial Result Label 2"}
return searchResultKeys, searchResultLabels, moreResults, nil
}
// In a real implementation, this function would perform a search based on the input.
// Here, we return a static set of options for demonstration purposes.
moreResults := 0
searchResultKeys = []string{"search-result-1", "search-result-2"}
searchResultLabels = []string{"Search Result Label 1", "Search Result Label 2"}
return searchResultKeys, searchResultLabels, moreResults, nil
}
selections, err := p.MultiSelectWithSearch("Select an option", "Search for an option", []string{}, persistentOptions, searchFunc)
if err != nil {
return err
}
if len(selections) == 0 {
fmt.Fprintln(io.Out, "No options selected.")
return nil
}
fmt.Fprintf(io.Out, "Selected options: %s\n", strings.Join(selections, ", "))
return nil
}
func runInput(p prompter.Prompter, io *iostreams.IOStreams) error {
fmt.Fprintln(io.Out, "Demonstrating Text Input")
text, err := p.Input("Favorite meal?", "Breakfast")