diff --git a/server/command.go b/server/command.go index 01ec01882..0bfce6115 100644 --- a/server/command.go +++ b/server/command.go @@ -12,6 +12,7 @@ import ( const helpText = "###### Mattermost Jira Plugin - Slash Command Help\n" + "* `/jira connect` - Connect your Mattermost account to your Jira account and subscribe to events\n" + "* `/jira disconnect` - Disonnect your Mattermost account from your Jira account\n" + + "* `/jira create ` - Create a new Issue with 'text' inserted into the description field.\n" + "* `/jira transition ` - Changes the state of a Jira issue.\n" + "\nFor system administrators:\n" + "* `/jira install cloud ` - connect Mattermost to a cloud Jira instance located at \n" + @@ -279,7 +280,7 @@ func getCommand() *model.Command { DisplayName: "Jira", Description: "Integration with Jira.", AutoComplete: true, - AutoCompleteDesc: "Available commands: connect, disconnect, transition, install cloud, install server, help", + AutoCompleteDesc: "Available commands: connect, disconnect, create, transition, install cloud, install server, help", AutoCompleteHint: "[command]", } } diff --git a/server/issue.go b/server/issue.go index 67dbd4548..2080eff70 100644 --- a/server/issue.go +++ b/server/issue.go @@ -26,8 +26,9 @@ func httpAPICreateIssue(ji Instance, w http.ResponseWriter, r *http.Request) (in api := ji.GetPlugin().API create := &struct { - PostId string `json:"post_id"` - Fields jira.IssueFields `json:"fields"` + PostId string `json:"post_id"` + ChannelId string `json:"channel_id"` + Fields jira.IssueFields `json:"fields"` }{} err := json.NewDecoder(r.Body).Decode(&create) if err != nil { @@ -50,34 +51,47 @@ func httpAPICreateIssue(ji Instance, w http.ResponseWriter, r *http.Request) (in return http.StatusInternalServerError, err } - // Lets add a permalink to the post in the Jira Description - post, appErr := api.GetPost(create.PostId) - if appErr != nil { - return http.StatusInternalServerError, - errors.WithMessage(appErr, "failed to load post "+create.PostId) - } - if post == nil { - return http.StatusInternalServerError, - errors.New("failed to load post " + create.PostId + ": not found") - } + var post *model.Post + var appErr *model.AppError - permalink, err := getPermaLink(ji, create.PostId, post) - if err != nil { - return http.StatusInternalServerError, - errors.New("failed to get permalink for " + create.PostId + ": not found") - } + // If this issue is attached to a post, lets add a permalink to the post in the Jira Description + if create.PostId != "" { + post, appErr = api.GetPost(create.PostId) + if appErr != nil { + return http.StatusInternalServerError, + errors.WithMessage(appErr, "failed to load post "+create.PostId) + } + if post == nil { + return http.StatusInternalServerError, + errors.New("failed to load post " + create.PostId + ": not found") + } + permalink, err2 := getPermaLink(ji, create.PostId, post) + if err2 != nil { + return http.StatusInternalServerError, + errors.New("failed to get permalink for " + create.PostId + ": not found") + } - if len(create.Fields.Description) > 0 { - create.Fields.Description += fmt.Sprintf("\n%v", permalink) - } else { - create.Fields.Description = permalink + if len(create.Fields.Description) > 0 { + create.Fields.Description += fmt.Sprintf("\n%v", permalink) + } else { + create.Fields.Description = permalink + } } created, resp, err := jiraClient.Issue.Create(&jira.Issue{ Fields: &create.Fields, }) + + // For now, if we are not attaching to a post, leave postId blank (this will only affect the error message) + postId := "" + channelId := create.ChannelId + if post != nil { + channelId = post.ChannelId + postId = create.PostId + } + if err != nil { - message := "failed to create the issue, postId: " + create.PostId + message := "failed to create the issue, postId: " + create.PostId + ", channelId: " + channelId if resp != nil { bb, _ := ioutil.ReadAll(resp.Body) resp.Body.Close() @@ -87,7 +101,7 @@ func httpAPICreateIssue(ji Instance, w http.ResponseWriter, r *http.Request) (in } // Upload file attachments in the background - if len(post.FileIds) > 0 { + if post != nil && len(post.FileIds) > 0 { go func() { for _, fileId := range post.FileIds { info, ae := api.GetFileInfo(fileId) @@ -107,7 +121,6 @@ func httpAPICreateIssue(ji Instance, w http.ResponseWriter, r *http.Request) (in api.LogError("failed to attach file to issue: "+e.Error(), "file", info.Path, "issue", created.Key) return } - } }() } @@ -116,26 +129,26 @@ func httpAPICreateIssue(ji Instance, w http.ResponseWriter, r *http.Request) (in reply := &model.Post{ // TODO: Why is this not created.Self? Message: fmt.Sprintf("Created a Jira issue %v/browse/%v", ji.GetURL(), created.Key), - ChannelId: post.ChannelId, - RootId: create.PostId, + ChannelId: channelId, + RootId: postId, UserId: mattermostUserId, } _, appErr = api.CreatePost(reply) if appErr != nil { return http.StatusInternalServerError, - errors.WithMessage(appErr, "failed to create notification post "+create.PostId) + errors.WithMessage(appErr, "failed to create notification post, postId: "+postId+", channelId: "+channelId) } userBytes, err := json.Marshal(created) if err != nil { return http.StatusInternalServerError, - errors.WithMessage(err, "failed to marshal response "+create.PostId) + errors.WithMessage(err, "failed to marshal response, postId: "+postId+", channelId: "+channelId) } w.Header().Set("Content-Type", "application/json") _, err = w.Write(userBytes) if err != nil { return http.StatusInternalServerError, - errors.WithMessage(err, "failed to write response "+create.PostId) + errors.WithMessage(err, "failed to write response, postId: "+postId+", channelId: "+channelId) } return http.StatusOK, nil } diff --git a/webapp/src/action_types/index.js b/webapp/src/action_types/index.js index 4c5e3cab6..c71519f67 100644 --- a/webapp/src/action_types/index.js +++ b/webapp/src/action_types/index.js @@ -6,6 +6,7 @@ import PluginId from 'plugin_id'; export default { CLOSE_CREATE_ISSUE_MODAL: `${PluginId}_close_create_modal`, OPEN_CREATE_ISSUE_MODAL: `${PluginId}_open_create_modal`, + OPEN_CREATE_ISSUE_MODAL_WITHOUT_POST: `${PluginId}_open_create_modal_without_post`, CLOSE_ATTACH_COMMENT_TO_ISSUE_MODAL: `${PluginId}_close_attach_modal`, OPEN_ATTACH_COMMENT_TO_ISSUE_MODAL: `${PluginId}_open_attach_modal`, diff --git a/webapp/src/actions/index.js b/webapp/src/actions/index.js index bbb5da0a8..aac3d1503 100644 --- a/webapp/src/actions/index.js +++ b/webapp/src/actions/index.js @@ -14,6 +14,14 @@ export const openCreateModal = (postId) => { }; }; +export const openCreateModalWithoutPost = (description, channelId) => (dispatch) => dispatch({ + type: ActionTypes.OPEN_CREATE_ISSUE_MODAL_WITHOUT_POST, + data: { + description, + channelId, + }, +}); + export const closeCreateModal = () => { return { type: ActionTypes.CLOSE_CREATE_ISSUE_MODAL, diff --git a/webapp/src/components/modals/create_issue/create_issue.jsx b/webapp/src/components/modals/create_issue/create_issue.jsx index 2e6b175dd..1c45432a5 100644 --- a/webapp/src/components/modals/create_issue/create_issue.jsx +++ b/webapp/src/components/modals/create_issue/create_issue.jsx @@ -33,6 +33,8 @@ export default class CreateIssueModal extends PureComponent { close: PropTypes.func.isRequired, create: PropTypes.func.isRequired, post: PropTypes.object, + description: PropTypes.string, + channelId: PropTypes.string, theme: PropTypes.object.isRequired, visible: PropTypes.bool.isRequired, jiraIssueMetadata: PropTypes.object, @@ -51,6 +53,11 @@ export default class CreateIssueModal extends PureComponent { const fields = {...this.state.fields}; fields.description = this.props.post.message; this.setState({fields}); //eslint-disable-line react/no-did-update-set-state + } else if (this.props.channelId && (this.props.channelId !== prevProps.channelId || this.props.description !== prevProps.description)) { + this.props.fetchJiraIssueMetadata(); + const fields = {...this.state.fields}; + fields.description = this.props.description; + this.setState({fields}); //eslint-disable-line react/no-did-update-set-state } } @@ -59,9 +66,14 @@ export default class CreateIssueModal extends PureComponent { e.preventDefault(); } + const {post, channelId} = this.props; + + const postId = (post) ? post.id : ''; + const issue = { - post_id: this.props.post.id, fields: this.state.fields, + post_id: postId, + channel_id: channelId, }; this.setState({submitting: true}); @@ -133,7 +145,7 @@ export default class CreateIssueModal extends PureComponent { } render() { - const {post, visible, theme, jiraIssueMetadata} = this.props; + const {visible, theme, jiraIssueMetadata} = this.props; const {error, submitting} = this.state; const style = getStyle(theme); @@ -146,7 +158,7 @@ export default class CreateIssueModal extends PureComponent { console.error('render error', error); //eslint-disable-line no-console } - if (!post || !jiraIssueMetadata || !jiraIssueMetadata.projects) { + if (!jiraIssueMetadata || !jiraIssueMetadata.projects) { component = ; } else { const issueOptions = getIssueValues(jiraIssueMetadata, this.state.projectKey); diff --git a/webapp/src/components/modals/create_issue/index.js b/webapp/src/components/modals/create_issue/index.js index 23d88d387..3e9c07ab2 100644 --- a/webapp/src/components/modals/create_issue/index.js +++ b/webapp/src/components/modals/create_issue/index.js @@ -7,13 +7,13 @@ import {bindActionCreators} from 'redux'; import {getPost} from 'mattermost-redux/selectors/entities/posts'; import {closeCreateModal, createIssue, fetchJiraIssueMetadata} from 'actions'; -import {isCreateModalVisible, getCreateModalForPostId, getJiraIssueMetadata} from 'selectors'; +import {isCreateModalVisible, getCreateModal, getJiraIssueMetadata} from 'selectors'; import CreateIssue from './create_issue'; const mapStateToProps = (state) => { - const postId = getCreateModalForPostId(state); - const post = getPost(state, postId); + const {postId, description, channelId} = getCreateModal(state); + const post = (postId) ? getPost(state, postId) : null; const jiraIssueMetadata = getJiraIssueMetadata(state); @@ -21,6 +21,8 @@ const mapStateToProps = (state) => { visible: isCreateModalVisible(state), jiraIssueMetadata, post, + description, + channelId, }; }; diff --git a/webapp/src/hooks/hooks.js b/webapp/src/hooks/hooks.js new file mode 100644 index 000000000..40c007adc --- /dev/null +++ b/webapp/src/hooks/hooks.js @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {openCreateModalWithoutPost} from '../actions'; + +export default class Hooks { + constructor(store) { + this.store = store; + } + + slashCommandWillBePostedHook = (message, contextArgs) => { + if (message && (message.startsWith('/jira create ') || message === '/jira create')) { + const description = message.slice(12).trim(); + this.store.dispatch(openCreateModalWithoutPost(description, contextArgs.channel_id)); + return Promise.resolve({}); + } + return Promise.resolve({message, args: contextArgs}); + } +} diff --git a/webapp/src/plugin.jsx b/webapp/src/plugin.jsx index a740bbd40..eb3089c3d 100644 --- a/webapp/src/plugin.jsx +++ b/webapp/src/plugin.jsx @@ -14,6 +14,7 @@ import PluginId from 'plugin_id'; import reducers from './reducers'; import {handleConnectChange, getConnected, openChannelSettings} from './actions'; +import Hooks from './hooks/hooks'; export default class Plugin { async initialize(registry, store) { @@ -32,6 +33,9 @@ export default class Plugin { ); registry.registerRootComponent(AttachCommentToIssueModal); registry.registerPostDropdownMenuComponent(AttachCommentToIssuePostMenuAction); + + const hooks = new Hooks(store); + registry.registerSlashCommandWillBePostedHook(hooks.slashCommandWillBePostedHook); } catch (err) { throw err; } finally { diff --git a/webapp/src/reducers/index.js b/webapp/src/reducers/index.js index 22988c3f4..8514eca8b 100644 --- a/webapp/src/reducers/index.js +++ b/webapp/src/reducers/index.js @@ -17,6 +17,7 @@ function connected(state = false, action) { const createModalVisible = (state = false, action) => { switch (action.type) { case ActionTypes.OPEN_CREATE_ISSUE_MODAL: + case ActionTypes.OPEN_CREATE_ISSUE_MODAL_WITHOUT_POST: return true; case ActionTypes.CLOSE_CREATE_ISSUE_MODAL: return false; @@ -25,12 +26,18 @@ const createModalVisible = (state = false, action) => { } }; -const createModalForPostId = (state = '', action) => { +const createModal = (state = '', action) => { switch (action.type) { case ActionTypes.OPEN_CREATE_ISSUE_MODAL: - return action.data.postId; + case ActionTypes.OPEN_CREATE_ISSUE_MODAL_WITHOUT_POST: + return { + ...state, + postId: action.data.postId, + description: action.data.description, + channelId: action.data.channelId, + }; case ActionTypes.CLOSE_CREATE_ISSUE_MODAL: - return ''; + return {}; default: return state; } @@ -93,7 +100,7 @@ const channelSubscripitons = (state = {}, action) => { export default combineReducers({ connected, createModalVisible, - createModalForPostId, + createModal, attachCommentToIssueModalVisible, attachCommentToIssueModalForPostId, jiraIssueMetadata, diff --git a/webapp/src/selectors/index.js b/webapp/src/selectors/index.js index da370849b..f5239220e 100644 --- a/webapp/src/selectors/index.js +++ b/webapp/src/selectors/index.js @@ -39,7 +39,7 @@ export const getCurrentUserLocale = createSelector( export const isCreateModalVisible = (state) => getPluginState(state).createModalVisible; -export const getCreateModalForPostId = (state) => getPluginState(state).createModalForPostId; +export const getCreateModal = (state) => getPluginState(state).createModal; export const isAttachCommentToIssueModalVisible = (state) => getPluginState(state).attachCommentToIssueModalVisible;