Merge pull request #12526 from cli/github-cli-1070-multi-select-with-search-ccr
`gh pr edit`: new interactive prompt for assignee selection, performance and accessibility improvements
This commit is contained in:
commit
6adf803127
11 changed files with 820 additions and 68 deletions
|
|
@ -701,6 +701,98 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in
|
|||
return client.REST(repo.RepoHost(), "DELETE", path, buf, nil)
|
||||
}
|
||||
|
||||
// SuggestedAssignableActors fetches up to 10 suggested actors for a specific assignable
|
||||
// (Issue or PullRequest) node ID. `assignableID` is the GraphQL node ID for the Issue/PR.
|
||||
// Returns the actors, the total count of available assignees in the repo, and an error.
|
||||
func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignableID string, query string) ([]AssignableActor, int, error) {
|
||||
type responseData struct {
|
||||
Repository struct {
|
||||
AssignableUsers struct {
|
||||
TotalCount int
|
||||
}
|
||||
} `graphql:"repository(owner: $owner, name: $name)"`
|
||||
Node struct {
|
||||
Issue struct {
|
||||
SuggestedActors struct {
|
||||
Nodes []struct {
|
||||
TypeName string `graphql:"__typename"`
|
||||
User struct {
|
||||
ID string
|
||||
Login string
|
||||
Name string
|
||||
} `graphql:"... on User"`
|
||||
Bot struct {
|
||||
ID string
|
||||
Login string
|
||||
} `graphql:"... on Bot"`
|
||||
}
|
||||
} `graphql:"suggestedActors(first: 10, query: $query)"`
|
||||
} `graphql:"... on Issue"`
|
||||
PullRequest struct {
|
||||
SuggestedActors struct {
|
||||
Nodes []struct {
|
||||
TypeName string `graphql:"__typename"`
|
||||
User struct {
|
||||
ID string
|
||||
Login string
|
||||
Name string
|
||||
} `graphql:"... on User"`
|
||||
Bot struct {
|
||||
ID string
|
||||
Login string
|
||||
} `graphql:"... on Bot"`
|
||||
}
|
||||
} `graphql:"suggestedActors(first: 10, query: $query)"`
|
||||
} `graphql:"... on PullRequest"`
|
||||
} `graphql:"node(id: $id)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"id": githubv4.ID(assignableID),
|
||||
"query": githubv4.String(query),
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"name": githubv4.String(repo.RepoName()),
|
||||
}
|
||||
|
||||
var result responseData
|
||||
if err := client.Query(repo.RepoHost(), "SuggestedAssignableActors", &result, variables); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
availableAssigneesCount := result.Repository.AssignableUsers.TotalCount
|
||||
|
||||
var nodes []struct {
|
||||
TypeName string `graphql:"__typename"`
|
||||
User struct {
|
||||
ID string
|
||||
Login string
|
||||
Name string
|
||||
} `graphql:"... on User"`
|
||||
Bot struct {
|
||||
ID string
|
||||
Login string
|
||||
} `graphql:"... on Bot"`
|
||||
}
|
||||
|
||||
if result.Node.PullRequest.SuggestedActors.Nodes != nil {
|
||||
nodes = result.Node.PullRequest.SuggestedActors.Nodes
|
||||
} else if result.Node.Issue.SuggestedActors.Nodes != nil {
|
||||
nodes = result.Node.Issue.SuggestedActors.Nodes
|
||||
}
|
||||
|
||||
actors := make([]AssignableActor, 0, len(nodes))
|
||||
|
||||
for _, n := range nodes {
|
||||
if n.TypeName == "User" && n.User.Login != "" {
|
||||
actors = append(actors, AssignableUser{id: n.User.ID, login: n.User.Login, name: n.User.Name})
|
||||
} else if n.TypeName == "Bot" && n.Bot.Login != "" {
|
||||
actors = append(actors, AssignableBot{id: n.Bot.ID, login: n.Bot.Login})
|
||||
}
|
||||
}
|
||||
|
||||
return actors, availableAssigneesCount, nil
|
||||
}
|
||||
|
||||
func UpdatePullRequestBranch(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestBranchInput) error {
|
||||
var mutation struct {
|
||||
UpdatePullRequestBranch struct {
|
||||
|
|
|
|||
|
|
@ -224,6 +224,217 @@ 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) prompter.MultiSelectSearchResult {
|
||||
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 prompter.MultiSelectSearchResult{
|
||||
Keys: searchResultKeys,
|
||||
Labels: searchResultLabels,
|
||||
MoreResults: moreResults,
|
||||
Err: 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 prompter.MultiSelectSearchResult{
|
||||
Keys: searchResultKeys,
|
||||
Labels: searchResultLabels,
|
||||
MoreResults: moreResults,
|
||||
Err: 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) prompter.MultiSelectSearchResult {
|
||||
// Initial search with no input
|
||||
if input == "" {
|
||||
moreResults := 2
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: initialSearchResultKeys,
|
||||
Labels: initialSearchResultLabels,
|
||||
MoreResults: moreResults,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// No search selected, so this should fail the test.
|
||||
t.FailNow()
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: nil,
|
||||
Labels: nil,
|
||||
MoreResults: 0,
|
||||
Err: 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) prompter.MultiSelectSearchResult {
|
||||
// Initial search with no input
|
||||
if input == "" {
|
||||
moreResults := 2
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: initialSearchResultKeys,
|
||||
Labels: initialSearchResultLabels,
|
||||
MoreResults: moreResults,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// Subsequent search with input "more"
|
||||
if input == "more" {
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: moreResultKeys,
|
||||
Labels: moreResultLabels,
|
||||
MoreResults: 0,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// No other searches expected
|
||||
t.FailNow()
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: nil,
|
||||
Labels: nil,
|
||||
MoreResults: 0,
|
||||
Err: 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("MultiSelectWithSearch - search error propagates", func(t *testing.T) {
|
||||
console := newTestVirtualTerminal(t)
|
||||
p := newTestAccessiblePrompter(t, console)
|
||||
|
||||
searchFunc := func(input string) prompter.MultiSelectSearchResult {
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Err: fmt.Errorf("search error"),
|
||||
}
|
||||
}
|
||||
|
||||
_, err := p.MultiSelectWithSearch("Select", "Search", []string{}, []string{}, searchFunc)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "search error")
|
||||
})
|
||||
|
||||
t.Run("Input", func(t *testing.T) {
|
||||
console := newTestVirtualTerminal(t)
|
||||
p := newTestAccessiblePrompter(t, console)
|
||||
|
|
@ -642,6 +853,9 @@ func newTestVirtualTerminal(t *testing.T) *expect.Console {
|
|||
failOnExpectError(t),
|
||||
failOnSendError(t),
|
||||
expect.WithDefaultTimeout(time.Second),
|
||||
// Use this logger to debug expect based tests by printing the
|
||||
// characters being read to stdout.
|
||||
// expect.WithLogger(log.New(os.Stdout, "", 0)),
|
||||
}
|
||||
|
||||
console, err := expect.NewConsole(consoleOpts...)
|
||||
|
|
|
|||
|
|
@ -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 has the signature: func(query string) MultiSelectSearchResult.
|
||||
// In the returned MultiSelectSearchResult, Keys are the values eventually returned by MultiSelectWithSearch and Labels are what is shown to the user in the prompt.
|
||||
MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) MultiSelectSearchResult) ([]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) MultiSelectSearchResult) ([]string, error) {
|
||||
return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
|
||||
}
|
||||
|
||||
type surveyPrompter struct {
|
||||
prompter *ghPrompter.Prompter
|
||||
stdin ghPrompter.FileReader
|
||||
|
|
@ -336,6 +349,160 @@ 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) MultiSelectSearchResult) ([]string, error) {
|
||||
return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
|
||||
}
|
||||
|
||||
type MultiSelectSearchResult struct {
|
||||
Keys []string
|
||||
Labels []string
|
||||
MoreResults int
|
||||
Err error
|
||||
}
|
||||
|
||||
func multiSelectWithSearch(p Prompter, prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]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
|
||||
}
|
||||
|
||||
searchResult := searchFunc("")
|
||||
if searchResult.Err != nil {
|
||||
return nil, fmt.Errorf("failed to search: %w", searchResult.Err)
|
||||
}
|
||||
searchResultKeys := searchResult.Keys
|
||||
searchResultLabels := searchResult.Labels
|
||||
moreResults := searchResult.MoreResults
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
searchResult := searchFunc(query)
|
||||
if searchResult.Err != nil {
|
||||
return nil, searchResult.Err
|
||||
}
|
||||
searchResultKeys = searchResult.Keys
|
||||
searchResultLabels = searchResult.Labels
|
||||
moreResults = searchResult.MoreResults
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) MultiSelectSearchResult) ([]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) MultiSelectSearchResult) ([]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) MultiSelectSearchResult
|
||||
}
|
||||
// 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) MultiSelectSearchResult) ([]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) MultiSelectSearchResult
|
||||
}{
|
||||
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) MultiSelectSearchResult
|
||||
} {
|
||||
var calls []struct {
|
||||
Prompt string
|
||||
SearchPrompt string
|
||||
Defaults []string
|
||||
PersistentOptions []string
|
||||
SearchFunc func(string) MultiSelectSearchResult
|
||||
}
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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,10 @@ type markdownEditorStub struct {
|
|||
fn func(string, string, bool) (string, error)
|
||||
}
|
||||
|
||||
type multiSelectWithSearchStub struct {
|
||||
fn func(string, string, []string, []string, func(string) MultiSelectSearchResult) ([]string, error)
|
||||
}
|
||||
|
||||
func (m *MockPrompter) AuthToken() (string, error) {
|
||||
var s authTokenStub
|
||||
if len(m.authTokenStubs) == 0 {
|
||||
|
|
@ -92,6 +97,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) MultiSelectSearchResult) ([]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, searchFunc)
|
||||
}
|
||||
|
||||
func (m *MockPrompter) RegisterAuthToken(stub func() (string, error)) {
|
||||
m.authTokenStubs = append(m.authTokenStubs, authTokenStub{fn: stub})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -845,7 +845,7 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) {
|
|||
{ "data": { "repository": { "suggestedActors": {
|
||||
"nodes": [
|
||||
{ "login": "hubot", "id": "HUBOTID", "__typename": "Bot" },
|
||||
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
|
||||
{ "login": "monalisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
fd "github.com/cli/cli/v2/internal/featuredetection"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
shared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
|
|
@ -292,6 +293,12 @@ func editRun(opts *EditOptions) error {
|
|||
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
// Wire up search functions for assignees and reviewers.
|
||||
// TODO KW: Wire up reviewer search func if/when it exists.
|
||||
if issueFeatures.ActorIsAssignable {
|
||||
editable.AssigneeSearchFunc = assigneeSearchFunc(apiClient, repo, &editable, pr.ID)
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable, opts.Detector.ProjectsV1())
|
||||
opts.IO.StopProgressIndicator()
|
||||
|
|
@ -331,6 +338,57 @@ func editRun(opts *EditOptions) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// assigneeSearchFunc is intended to be an arg for MultiSelectWithSearch
|
||||
// to return potential assignee actors.
|
||||
// It also contains an important enclosure to update the editable's
|
||||
// assignable actors metadata for later ID resolution - this is required
|
||||
// while we continue to use IDs for mutating assignees with the GQL API.
|
||||
func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable *shared.Editable, assignableID string) func(string) prompter.MultiSelectSearchResult {
|
||||
searchFunc := func(input string) prompter.MultiSelectSearchResult {
|
||||
actors, availableAssigneesCount, err := api.SuggestedAssignableActors(
|
||||
apiClient,
|
||||
repo,
|
||||
assignableID,
|
||||
input)
|
||||
if err != nil {
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: nil,
|
||||
Labels: nil,
|
||||
MoreResults: 0,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
logins := make([]string, 0, len(actors))
|
||||
displayNames := make([]string, 0, len(actors))
|
||||
|
||||
for _, a := range actors {
|
||||
if a.Login() != "" {
|
||||
logins = append(logins, a.Login())
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
if a.DisplayName() != "" {
|
||||
displayNames = append(displayNames, a.DisplayName())
|
||||
} else {
|
||||
displayNames = append(displayNames, a.Login())
|
||||
}
|
||||
|
||||
// Update the assignable actors metadata in the editable struct
|
||||
// so that updating the PR later can resolve the actor ID.
|
||||
editable.Metadata.AssignableActors = append(editable.Metadata.AssignableActors, a)
|
||||
}
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: logins,
|
||||
Labels: displayNames,
|
||||
MoreResults: availableAssigneesCount,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
return searchFunc
|
||||
}
|
||||
|
||||
func updatePullRequest(httpClient *http.Client, repo ghrepo.Interface, id string, number int, editable shared.Editable) error {
|
||||
var wg errgroup.Group
|
||||
wg.Go(func() error {
|
||||
|
|
|
|||
|
|
@ -714,7 +714,13 @@ func Test_editRun(t *testing.T) {
|
|||
editFields: func(e *shared.Editable, _ string) error {
|
||||
e.Title.Value = "new title"
|
||||
e.Body.Value = "new body"
|
||||
e.Assignees.Value = []string{"monalisa", "hubot"}
|
||||
// When ActorAssignees is enabled, the interactive flow returns display names (or logins for non-users)
|
||||
e.Assignees.Value = []string{"monalisa (Mona Display Name)", "hubot"}
|
||||
// Populate metadata to simulate what searchFunc would do during prompting
|
||||
e.Metadata.AssignableActors = []api.AssignableActor{
|
||||
api.NewAssignableBot("HUBOTID", "hubot"),
|
||||
api.NewAssignableUser("MONAID", "monalisa", "Mona Display Name"),
|
||||
}
|
||||
e.Labels.Value = []string{"feature", "TODO", "bug"}
|
||||
e.Labels.Add = []string{"feature", "TODO", "bug"}
|
||||
e.Labels.Remove = []string{"docs"}
|
||||
|
|
@ -728,7 +734,8 @@ func Test_editRun(t *testing.T) {
|
|||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
// interactive but reviewers not chosen; need everything except reviewers/teams
|
||||
mockRepoMetadata(reg, mockRepoMetadataOptions{assignees: true, labels: true, projects: true, milestones: true})
|
||||
// assignees: false because searchFunc handles dynamic fetching (metadata populated in test mock)
|
||||
mockRepoMetadata(reg, mockRepoMetadataOptions{assignees: false, labels: true, projects: true, milestones: true})
|
||||
mockPullRequestUpdate(reg)
|
||||
mockPullRequestUpdateActorAssignees(reg)
|
||||
mockPullRequestUpdateLabels(reg)
|
||||
|
|
@ -822,8 +829,13 @@ func Test_editRun(t *testing.T) {
|
|||
require.Equal(t, []string{"hubot"}, e.Assignees.Default)
|
||||
require.Equal(t, []string{"hubot"}, e.Assignees.DefaultLogins)
|
||||
|
||||
// Adding MonaLisa as PR assignee, should preserve hubot.
|
||||
e.Assignees.Value = []string{"hubot", "MonaLisa (Mona Display Name)"}
|
||||
// Adding monalisa as PR assignee, should preserve hubot.
|
||||
e.Assignees.Value = []string{"hubot", "monalisa (Mona Display Name)"}
|
||||
// Populate metadata to simulate what searchFunc would do during prompting
|
||||
e.Metadata.AssignableActors = []api.AssignableActor{
|
||||
api.NewAssignableBot("HUBOTID", "hubot"),
|
||||
api.NewAssignableUser("MONAID", "monalisa", "Mona Display Name"),
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
|
@ -831,17 +843,6 @@ func Test_editRun(t *testing.T) {
|
|||
EditorRetriever: testEditorRetriever{},
|
||||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "suggestedActors": {
|
||||
"nodes": [
|
||||
{ "login": "hubot", "id": "HUBOTID", "__typename": "Bot" },
|
||||
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
mockPullRequestUpdate(reg)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
||||
|
|
@ -886,7 +887,7 @@ func Test_editRun(t *testing.T) {
|
|||
{ "data": { "repository": { "assignableUsers": {
|
||||
"nodes": [
|
||||
{ "login": "hubot", "id": "HUBOTID" },
|
||||
{ "login": "MonaLisa", "id": "MONAID" }
|
||||
{ "login": "monalisa", "id": "MONAID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
|
|
@ -895,6 +896,55 @@ func Test_editRun(t *testing.T) {
|
|||
},
|
||||
stdout: "https://github.com/OWNER/REPO/pull/123\n",
|
||||
},
|
||||
{
|
||||
name: "interactive GHES uses legacy assignee flow without search",
|
||||
input: &EditOptions{
|
||||
Detector: &fd.DisabledDetectorMock{},
|
||||
SelectorArg: "123",
|
||||
Finder: shared.NewMockFinder("123", &api.PullRequest{
|
||||
URL: "https://github.com/OWNER/REPO/pull/123",
|
||||
Assignees: api.Assignees{
|
||||
Nodes: []api.GitHubUser{{Login: "octocat", ID: "OCTOID"}},
|
||||
TotalCount: 1,
|
||||
},
|
||||
}, ghrepo.New("OWNER", "REPO")),
|
||||
Interactive: true,
|
||||
Surveyor: testSurveyor{
|
||||
fieldsToEdit: func(e *shared.Editable) error {
|
||||
e.Assignees.Edited = true
|
||||
return nil
|
||||
},
|
||||
editFields: func(e *shared.Editable, _ string) error {
|
||||
require.False(t, e.Assignees.ActorAssignees)
|
||||
require.Nil(t, e.AssigneeSearchFunc)
|
||||
require.Contains(t, e.Assignees.Options, "monalisa")
|
||||
require.Contains(t, e.Assignees.Options, "hubot")
|
||||
|
||||
e.Assignees.Value = []string{"monalisa", "hubot"}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Fetcher: testFetcher{},
|
||||
EditorRetriever: testEditorRetriever{},
|
||||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
reg.Exclude(t, httpmock.GraphQL(`query RepositoryAssignableActors\b`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "assignableUsers": {
|
||||
"nodes": [
|
||||
{ "login": "hubot", "id": "HUBOTID" },
|
||||
{ "login": "monalisa", "id": "MONAID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Exclude(t, httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`))
|
||||
mockPullRequestUpdate(reg)
|
||||
},
|
||||
stdout: "https://github.com/OWNER/REPO/pull/123\n",
|
||||
},
|
||||
{
|
||||
name: "non-interactive projects v1 unsupported doesn't fetch v1 metadata",
|
||||
input: &EditOptions{
|
||||
|
|
@ -1001,7 +1051,7 @@ func mockRepoMetadata(reg *httpmock.Registry, opt mockRepoMetadataOptions) {
|
|||
{ "data": { "repository": { "suggestedActors": {
|
||||
"nodes": [
|
||||
{ "login": "hubot", "id": "HUBOTID", "__typename": "Bot" },
|
||||
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
|
||||
{ "login": "monalisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
|
|
|
|||
|
|
@ -6,19 +6,22 @@ import (
|
|||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/pkg/set"
|
||||
)
|
||||
|
||||
type Editable struct {
|
||||
Title EditableString
|
||||
Body EditableString
|
||||
Base EditableString
|
||||
Reviewers EditableSlice
|
||||
Assignees EditableAssignees
|
||||
Labels EditableSlice
|
||||
Projects EditableProjects
|
||||
Milestone EditableString
|
||||
Metadata api.RepoMetadataResult
|
||||
Title EditableString
|
||||
Body EditableString
|
||||
Base EditableString
|
||||
Reviewers EditableSlice
|
||||
ReviewerSearchFunc func(string) ([]string, []string, error)
|
||||
Assignees EditableAssignees
|
||||
AssigneeSearchFunc func(string) prompter.MultiSelectSearchResult
|
||||
Labels EditableSlice
|
||||
Projects EditableProjects
|
||||
Milestone EditableString
|
||||
Metadata api.RepoMetadataResult
|
||||
}
|
||||
|
||||
type EditableString struct {
|
||||
|
|
@ -277,6 +280,7 @@ type EditPrompter interface {
|
|||
Input(string, string) (string, error)
|
||||
MarkdownEditor(string, string, bool) (string, error)
|
||||
MultiSelect(string, []string, []string) ([]int, error)
|
||||
MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error)
|
||||
Confirm(string, bool) (bool, error)
|
||||
}
|
||||
|
||||
|
|
@ -302,10 +306,24 @@ func EditFieldsSurvey(p EditPrompter, editable *Editable, editorCommand string)
|
|||
}
|
||||
}
|
||||
if editable.Assignees.Edited {
|
||||
editable.Assignees.Value, err = multiSelectSurvey(
|
||||
p, "Assignees", editable.Assignees.Default, editable.Assignees.Options)
|
||||
if err != nil {
|
||||
return err
|
||||
if editable.AssigneeSearchFunc != nil {
|
||||
editable.Assignees.Options = []string{}
|
||||
editable.Assignees.Value, err = p.MultiSelectWithSearch(
|
||||
"Assignees",
|
||||
"Search assignees",
|
||||
editable.Assignees.DefaultLogins,
|
||||
// No persistent options required here as teams cannot be assignees.
|
||||
[]string{},
|
||||
editable.AssigneeSearchFunc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
editable.Assignees.Value, err = multiSelectSurvey(
|
||||
p, "Assignees", editable.Assignees.Default, editable.Assignees.Options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if editable.Labels.Edited {
|
||||
|
|
@ -408,10 +426,27 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable,
|
|||
teamReviewers = true
|
||||
}
|
||||
}
|
||||
|
||||
fetchAssignees := false
|
||||
if editable.Assignees.Edited {
|
||||
// Similar as above, this is likely an interactive flow if no Add/Remove slices are set.
|
||||
// The addition here is that we also check for an assignee search func.
|
||||
// If we have a search func, we don't need to fetch assignees since we
|
||||
// assume that will be done dynamically in the prompting flow.
|
||||
if len(editable.Assignees.Add) == 0 && len(editable.Assignees.Remove) == 0 && editable.AssigneeSearchFunc == nil {
|
||||
fetchAssignees = true
|
||||
}
|
||||
// However, if we have Add/Remove operations (non-interactive flow),
|
||||
// we do need to fetch the assignees.
|
||||
if len(editable.Assignees.Add) > 0 || len(editable.Assignees.Remove) > 0 {
|
||||
fetchAssignees = true
|
||||
}
|
||||
}
|
||||
|
||||
input := api.RepoMetadataInput{
|
||||
Reviewers: editable.Reviewers.Edited,
|
||||
TeamReviewers: teamReviewers,
|
||||
Assignees: editable.Assignees.Edited,
|
||||
Assignees: fetchAssignees,
|
||||
ActorAssignees: editable.Assignees.ActorAssignees,
|
||||
Labels: editable.Labels.Edited,
|
||||
ProjectsV1: editable.Projects.Edited && projectV1Support == gh.ProjectsV1Supported,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/surveyext"
|
||||
|
|
@ -35,11 +36,12 @@ const (
|
|||
)
|
||||
|
||||
type Prompt interface {
|
||||
Input(string, string) (string, error)
|
||||
Select(string, string, []string) (int, error)
|
||||
MarkdownEditor(string, string, bool) (string, error)
|
||||
Confirm(string, bool) (bool, error)
|
||||
MultiSelect(string, []string, []string) ([]int, error)
|
||||
Input(prompt string, defaultValue string) (string, error)
|
||||
Select(prompt string, defaultValue string, options []string) (int, error)
|
||||
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) prompter.MultiSelectSearchResult) ([]string, error)
|
||||
}
|
||||
|
||||
func ConfirmIssueSubmission(p Prompt, allowPreview bool, allowMetadata bool) (Action, error) {
|
||||
|
|
|
|||
|
|
@ -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,52 @@ 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) prompter.MultiSelectSearchResult {
|
||||
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 prompter.MultiSelectSearchResult{
|
||||
Keys: searchResultKeys,
|
||||
Labels: searchResultLabels,
|
||||
MoreResults: moreResults,
|
||||
Err: 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 prompter.MultiSelectSearchResult{
|
||||
Keys: searchResultKeys,
|
||||
Labels: searchResultLabels,
|
||||
MoreResults: moreResults,
|
||||
Err: 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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue