diff --git a/server/http.go b/server/http.go index 7d478dd94..ad317a8df 100644 --- a/server/http.go +++ b/server/http.go @@ -22,7 +22,6 @@ const ( routeAPIGetCreateIssueMetadata = "/api/v2/get-create-issue-metadata-for-project" routeAPIGetJiraProjectMetadata = "/api/v2/get-jira-project-metadata" routeAPIGetSearchIssues = "/api/v2/get-search-issues" - routeAPIGetSearchEpics = "/api/v2/get-search-epics" routeAPIAttachCommentToIssue = "/api/v2/attach-comment-to-issue" routeAPIUserInfo = "/api/v2/userinfo" routeAPISubscribeWebhook = "/api/v2/webhook" @@ -73,8 +72,6 @@ func handleHTTPRequest(p *Plugin, w http.ResponseWriter, r *http.Request) (int, return withInstance(p.currentInstanceStore, w, r, httpAPIGetJiraProjectMetadata) case routeAPIGetSearchIssues: return withInstance(p.currentInstanceStore, w, r, httpAPIGetSearchIssues) - case routeAPIGetSearchEpics: - return withInstance(p.currentInstanceStore, w, r, httpAPIGetSearchEpics) case routeAPIAttachCommentToIssue: return withInstance(p.currentInstanceStore, w, r, httpAPIAttachCommentToIssue) diff --git a/server/issue.go b/server/issue.go index 329123df5..05a4fb924 100644 --- a/server/issue.go +++ b/server/issue.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "regexp" + "strconv" "strings" "sync" @@ -265,7 +266,7 @@ func httpAPIGetCreateIssueMetadataForProjects(ji Instance, w http.ResponseWriter return http.StatusOK, nil } -func httpAPIGetSearchEpics(ji Instance, w http.ResponseWriter, r *http.Request) (int, error) { +func httpAPIGetSearchIssues(ji Instance, w http.ResponseWriter, r *http.Request) (int, error) { if r.Method != http.MethodGet { return http.StatusMethodNotAllowed, errors.New("Request: " + r.Method + " is not allowed, must be GET") @@ -286,23 +287,35 @@ func httpAPIGetSearchEpics(ji Instance, w http.ResponseWriter, r *http.Request) return http.StatusInternalServerError, err } - epicNameTypeID := r.FormValue("epic_name_type_id") - jqlString := r.FormValue("jql") q := r.FormValue("q") + jqlString := r.FormValue("jql") + fieldsStr := r.FormValue("fields") + limitStr := r.FormValue("limit") - if len(epicNameTypeID) == 0 { - return http.StatusBadRequest, errors.New("epic_name_type_id query param is required") + if len(fieldsStr) == 0 { + fieldsStr = "key,summary" } if len(jqlString) == 0 { - return http.StatusBadRequest, errors.New("jql query param is required") + escaped := strings.ReplaceAll(q, `"`, `\"`) + jqlString = fmt.Sprintf(`text ~ "%s" OR text ~ "%s*"`, escaped, escaped) + } + + limit := 50 + if len(limitStr) > 0 { + parsedLimit, parseErr := strconv.Atoi(limitStr) + if parseErr == nil { + limit = parsedLimit + } } + fields := strings.Split(fieldsStr, ",") + var exact *jira.Issue var wg sync.WaitGroup if reJiraIssueKey.MatchString(q) { wg.Add(1) go func() { - exact, _ = client.GetIssue(q, nil) + exact, _ = client.GetIssue(q, &jira.GetQueryOptions{Fields: fieldsStr}) wg.Done() }() } @@ -311,34 +324,22 @@ func httpAPIGetSearchEpics(ji Instance, w http.ResponseWriter, r *http.Request) wg.Add(1) go func() { found, _ = client.SearchIssues(jqlString, &jira.SearchOptions{ - MaxResults: 50, - Fields: []string{epicNameTypeID}, + MaxResults: limit, + Fields: fields, }) + wg.Done() }() wg.Wait() - result := []utils.ReactSelectOption{} + result := []jira.Issue{} if exact != nil { - name, _ := exact.Fields.Unknowns.String(epicNameTypeID) - if name != "" { - label := fmt.Sprintf("%s: %s", exact.Key, name) - result = append(result, utils.ReactSelectOption{ - Label: label, - Value: exact.Key, - }) - } + result = append(result, *exact) } - for _, epic := range found { - name, _ := epic.Fields.Unknowns.String(epicNameTypeID) - if name != "" { - label := fmt.Sprintf("%s: %s", epic.Key, name) - result = append(result, utils.ReactSelectOption{ - Label: label, - Value: epic.Key, - }) - } + + for _, issue := range found { + result = append(result, issue) } bb, err := json.Marshal(result) @@ -437,80 +438,6 @@ func httpAPIGetJiraProjectMetadata(ji Instance, w http.ResponseWriter, r *http.R var reJiraIssueKey = regexp.MustCompile(`^([[:alpha:]]+)-([[:digit:]]+)$`) -func httpAPIGetSearchIssues(ji Instance, w http.ResponseWriter, r *http.Request) (int, error) { - if r.Method != http.MethodGet { - return http.StatusMethodNotAllowed, - errors.New("Request: " + r.Method + " is not allowed, must be GET") - } - - mattermostUserId := r.Header.Get("Mattermost-User-Id") - if mattermostUserId == "" { - return http.StatusUnauthorized, errors.New("not authorized") - } - - jiraUser, err := ji.GetPlugin().userStore.LoadJIRAUser(ji, mattermostUserId) - if err != nil { - return http.StatusInternalServerError, err - } - - client, err := ji.GetClient(jiraUser) - if err != nil { - return http.StatusInternalServerError, err - } - - q := r.FormValue("q") - var exact *jira.Issue - var wg sync.WaitGroup - if reJiraIssueKey.MatchString(q) { - wg.Add(1) - go func() { - exact, _ = client.GetIssue(q, nil) - wg.Done() - }() - } - - jqlString := `text ~ "` + strings.ReplaceAll(q, `"`, `\"`) + `"` - var found []jira.Issue - wg.Add(1) - go func() { - found, _ = client.SearchIssues(jqlString, &jira.SearchOptions{ - MaxResults: 50, - Fields: []string{"key", "summary"}, - }) - wg.Done() - }() - - wg.Wait() - - var result []utils.ReactSelectOption - if exact != nil { - result = append(result, utils.ReactSelectOption{ - Value: exact.Key, - Label: exact.Key + ": " + exact.Fields.Summary, - }) - } - for _, issue := range found { - result = append(result, utils.ReactSelectOption{ - Value: issue.Key, - Label: issue.Key + ": " + issue.Fields.Summary, - }) - } - - w.Header().Set("Content-Type", "application/json") - b, err := json.Marshal(result) - if err != nil { - return http.StatusInternalServerError, - errors.WithMessage(err, "failed to marshal response") - } - _, err = w.Write(b) - if err != nil { - return http.StatusInternalServerError, - errors.WithMessage(err, "failed to write response") - } - - return http.StatusOK, nil -} - func httpAPIAttachCommentToIssue(ji Instance, w http.ResponseWriter, r *http.Request) (int, error) { if r.Method != http.MethodPost { return http.StatusMethodNotAllowed, diff --git a/webapp/src/actions/index.js b/webapp/src/actions/index.js index 10974ea8b..3e5810ac9 100644 --- a/webapp/src/actions/index.js +++ b/webapp/src/actions/index.js @@ -95,9 +95,9 @@ export const fetchJiraProjectMetadata = () => { }; }; -export const fetchEpicsWithParams = (params) => { +export const searchIssues = (params) => { return async (dispatch, getState) => { - const url = getPluginServerRoute(getState()) + '/api/v2/get-search-epics'; + const url = getPluginServerRoute(getState()) + '/api/v2/get-search-issues'; return doFetchWithResponse(`${url}${buildQueryString(params)}`); }; }; diff --git a/webapp/src/components/jira_epic_selector/__snapshots__/jira_epic_selector.test.tsx.snap b/webapp/src/components/jira_epic_selector/__snapshots__/jira_epic_selector.test.tsx.snap index a07fbd0fb..53827aa82 100644 --- a/webapp/src/components/jira_epic_selector/__snapshots__/jira_epic_selector.test.tsx.snap +++ b/webapp/src/components/jira_epic_selector/__snapshots__/jira_epic_selector.test.tsx.snap @@ -17,12 +17,16 @@ exports[`components/JiraEpicSelector should match snapshot 1`] = ` ], } } - fetchEpicsWithParams={ + inputId="epic" + isMulti={true} + onChange={[MockFunction]} + removeValidate={[MockFunction]} + searchIssues={ [MockFunction] { "calls": Array [ Array [ Object { - "epic_name_type_id": "customfield_10011", + "fields": "customfield_10011", "jql": "project=KT and issuetype=10000 and id IN (KT-17, KT-20) ORDER BY updated DESC", "q": "", }, @@ -36,10 +40,6 @@ exports[`components/JiraEpicSelector should match snapshot 1`] = ` ], } } - inputId="epic" - isMulti={true} - onChange={[MockFunction]} - removeValidate={[MockFunction]} theme={ Object { "awayIndicator": "#ffbc42", @@ -93,12 +93,20 @@ exports[`components/JiraEpicSelector should match snapshot 1`] = ` } cacheOptions={false} defaultOptions={true} - fetchEpicsWithParams={ + filterOption={null} + isMulti={true} + loadOptions={[Function]} + menuPlacement="auto" + menuPortalTarget={} + name="epic" + onChange={[Function]} + removeValidate={[MockFunction]} + searchIssues={ [MockFunction] { "calls": Array [ Array [ Object { - "epic_name_type_id": "customfield_10011", + "fields": "customfield_10011", "jql": "project=KT and issuetype=10000 and id IN (KT-17, KT-20) ORDER BY updated DESC", "q": "", }, @@ -112,14 +120,6 @@ exports[`components/JiraEpicSelector should match snapshot 1`] = ` ], } } - filterOption={null} - isMulti={true} - loadOptions={[Function]} - menuPlacement="auto" - menuPortalTarget={} - name="epic" - onChange={[Function]} - removeValidate={[MockFunction]} styles={ Object { "clearIndicator": [Function], diff --git a/webapp/src/components/jira_epic_selector/index.ts b/webapp/src/components/jira_epic_selector/index.ts index 4a32d7168..11debe6c0 100644 --- a/webapp/src/components/jira_epic_selector/index.ts +++ b/webapp/src/components/jira_epic_selector/index.ts @@ -4,12 +4,12 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {fetchEpicsWithParams} from 'actions'; +import {searchIssues} from 'actions'; import JiraEpicSelector from './jira_epic_selector'; const mapDispatchToProps = (dispatch) => bindActionCreators({ - fetchEpicsWithParams, + searchIssues, }, dispatch); export default connect(null, mapDispatchToProps)(JiraEpicSelector); diff --git a/webapp/src/components/jira_epic_selector/jira_epic_selector.test.tsx b/webapp/src/components/jira_epic_selector/jira_epic_selector.test.tsx index 76a1d99d3..44a61fabb 100644 --- a/webapp/src/components/jira_epic_selector/jira_epic_selector.test.tsx +++ b/webapp/src/components/jira_epic_selector/jira_epic_selector.test.tsx @@ -14,7 +14,7 @@ import JiraEpicSelector from './jira_epic_selector'; describe('components/JiraEpicSelector', () => { const baseProps = { - fetchEpicsWithParams: jest.fn().mockResolvedValue({}), + searchIssues: jest.fn().mockResolvedValue({}), issueMetadata: issueMetadata as IssueMetadata, theme: Preferences.THEMES.default, isMulti: true, @@ -32,27 +32,27 @@ describe('components/JiraEpicSelector', () => { expect(wrapper).toMatchSnapshot(); }); - test('should call fetchEpicsWithParams on mount if values are present', () => { + test('should call searchIssues on mount if values are present', () => { const props = {...baseProps}; const wrapper = shallow( ); - expect(props.fetchEpicsWithParams).toHaveBeenCalledWith({ - epic_name_type_id: 'customfield_10011', + expect(props.searchIssues).toHaveBeenCalledWith({ + fields: 'customfield_10011', jql: 'project=KT and issuetype=10000 and id IN (KT-17, KT-20) ORDER BY updated DESC', q: '', }); }); - test('should not call fetchEpicsWithParams on mount if no values are present', () => { + test('should not call searchIssues on mount if no values are present', () => { const props = {...baseProps, value: []}; const wrapper = shallow( ); - expect(props.fetchEpicsWithParams).not.toHaveBeenCalled(); + expect(props.searchIssues).not.toHaveBeenCalled(); }); - test('#searchIssues should call fetchEpicsWithParams', () => { + test('#searchIssues should call searchIssues', () => { const props = {...baseProps}; const wrapper = shallow( @@ -60,18 +60,18 @@ describe('components/JiraEpicSelector', () => { wrapper.instance().searchIssues(''); - let args = props.fetchEpicsWithParams.mock.calls[1][0]; + let args = props.searchIssues.mock.calls[1][0]; expect(args).toEqual({ - epic_name_type_id: 'customfield_10011', + fields: 'customfield_10011', jql: 'project=KT and issuetype=10000 ORDER BY updated DESC', q: '', }); wrapper.instance().searchIssues('some input'); - args = props.fetchEpicsWithParams.mock.calls[2][0]; + args = props.searchIssues.mock.calls[2][0]; expect(args).toEqual({ - epic_name_type_id: 'customfield_10011', + fields: 'customfield_10011', jql: 'project=KT and issuetype=10000 and ("Epic Name"~"some input" or "Epic Name"~"some input*") ORDER BY updated DESC', q: 'some input', }); diff --git a/webapp/src/components/jira_epic_selector/jira_epic_selector.tsx b/webapp/src/components/jira_epic_selector/jira_epic_selector.tsx index 675f87897..f81729595 100644 --- a/webapp/src/components/jira_epic_selector/jira_epic_selector.tsx +++ b/webapp/src/components/jira_epic_selector/jira_epic_selector.tsx @@ -8,7 +8,7 @@ import AsyncSelect from 'react-select/async'; import {getStyleForReactSelect} from 'utils/styles'; import {isEpicNameField, isEpicIssueType} from 'utils/jira_issue_metadata'; -import {IssueMetadata, ReactSelectOption} from 'types/model'; +import {IssueMetadata, ReactSelectOption, JiraIssue} from 'types/model'; import Setting from 'components/setting'; @@ -18,7 +18,7 @@ const searchDebounceDelay = 400; type Props = { required?: boolean; hideRequiredStar?: boolean; - fetchEpicsWithParams: (params: object) => Promise<{data: ReactSelectOption[]}>; + searchIssues: (params: object) => Promise<{data: JiraIssue[]}>; theme: object; isMulti?: boolean; onChange: (values: string[]) => void; @@ -128,12 +128,15 @@ export default class JiraEpicSelector extends React.PureComponent const params = { jql: fullJql, - epic_name_type_id: epicNameTypeId, + fields: epicNameTypeId, q: userInput, }; - return this.props.fetchEpicsWithParams(params).then(({data}) => { - return data; + return this.props.searchIssues(params).then(({data}: {data: JiraIssue[]}) => { + return data.map((issue) => ({ + value: issue.key, + label: `${issue.key}: ${issue.fields[epicNameTypeId]}`, + })); }).catch((e) => { this.setState({error: e}); return []; diff --git a/webapp/src/components/jira_issue_selector/index.js b/webapp/src/components/jira_issue_selector/index.js index debddf657..170b76a4f 100644 --- a/webapp/src/components/jira_issue_selector/index.js +++ b/webapp/src/components/jira_issue_selector/index.js @@ -2,15 +2,14 @@ // See LICENSE.txt for license information. import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; -import {getPluginServerRoute} from 'selectors'; +import {searchIssues} from 'actions'; import JiraIssueSelector from './jira_issue_selector'; -const mapStateToProps = (state) => { - return { - fetchIssuesEndpoint: getPluginServerRoute(state) + '/api/v2/get-search-issues', - }; -}; +const mapDispatchToProps = (dispatch) => bindActionCreators({ + searchIssues, +}, dispatch); -export default connect(mapStateToProps, null, null, {withRef: true})(JiraIssueSelector); +export default connect(null, mapDispatchToProps, null, {withRef: true})(JiraIssueSelector); diff --git a/webapp/src/components/jira_issue_selector/jira_issue_selector.jsx b/webapp/src/components/jira_issue_selector/jira_issue_selector.jsx index 76aad046c..6e403c8b7 100644 --- a/webapp/src/components/jira_issue_selector/jira_issue_selector.jsx +++ b/webapp/src/components/jira_issue_selector/jira_issue_selector.jsx @@ -8,7 +8,6 @@ import debounce from 'debounce-promise'; import AsyncSelect from 'react-select/async'; import {getStyleForReactSelect} from 'utils/styles'; -import {doFetchWithResponse} from 'client'; const searchDebounceDelay = 400; @@ -17,9 +16,9 @@ export default class JiraIssueSelector extends Component { required: PropTypes.bool, theme: PropTypes.object.isRequired, onChange: PropTypes.func.isRequired, - fetchIssuesEndpoint: PropTypes.string.isRequired, + searchIssues: PropTypes.func.isRequired, error: PropTypes.string, - value: PropTypes.object, + value: PropTypes.string, addValidate: PropTypes.func.isRequired, removeValidate: PropTypes.func.isRequired, }; @@ -53,8 +52,16 @@ export default class JiraIssueSelector extends Component { }; searchIssues = (text) => { - return doFetchWithResponse(this.props.fetchIssuesEndpoint + `?q=${encodeURIComponent(text.trim())}`).then(({data}) => { - return data; + const params = { + fields: 'key,summary', + q: text.trim(), + }; + + return this.props.searchIssues(params).then(({data}) => { + return data.map((issue) => ({ + value: issue.key, + label: `${issue.key}: ${issue.fields.summary}`, + })); }).catch((e) => { this.setState({error: e}); }); diff --git a/webapp/src/components/modals/attach_comment_to_issue/attach_comment_to_issue.jsx b/webapp/src/components/modals/attach_comment_to_issue/attach_comment_to_issue.jsx index ab934b9e0..2ef2b73d1 100644 --- a/webapp/src/components/modals/attach_comment_to_issue/attach_comment_to_issue.jsx +++ b/webapp/src/components/modals/attach_comment_to_issue/attach_comment_to_issue.jsx @@ -96,6 +96,8 @@ export default class AttachIssueModal extends PureComponent { value={this.state.issueKey} />