From 6297922ab9561dbf774ab5d51619abfc4a411e40 Mon Sep 17 00:00:00 2001 From: Brian Olecki Date: Tue, 15 Nov 2016 10:43:16 -0500 Subject: Add support for editing slash commands (#4335) --- api/command.go | 60 ++ api/command_test.go | 39 ++ i18n/en.json | 8 + model/client.go | 10 + webapp/client/client.jsx | 12 + .../integrations/components/edit_command.jsx | 731 +++++++++++++++++++++ .../integrations/components/installed_command.jsx | 9 + webapp/i18n/en.json | 7 + webapp/routes/route_integrations.jsx | 6 + webapp/stores/integration_store.jsx | 9 + webapp/tests/client_command.test.jsx | 28 + webapp/utils/async_client.jsx | 23 + 12 files changed, 942 insertions(+) create mode 100644 webapp/components/integrations/components/edit_command.jsx diff --git a/api/command.go b/api/command.go index e71661a67..ff0f72149 100644 --- a/api/command.go +++ b/api/command.go @@ -45,6 +45,7 @@ func InitCommand() { BaseRoutes.Commands.Handle("/list", ApiUserRequired(listCommands)).Methods("GET") BaseRoutes.Commands.Handle("/create", ApiUserRequired(createCommand)).Methods("POST") + BaseRoutes.Commands.Handle("/update", ApiUserRequired(updateCommand)).Methods("POST") BaseRoutes.Commands.Handle("/list_team_commands", ApiUserRequired(listTeamCommands)).Methods("GET") BaseRoutes.Commands.Handle("/regen_token", ApiUserRequired(regenCommandToken)).Methods("POST") BaseRoutes.Commands.Handle("/delete", ApiUserRequired(deleteCommand)).Methods("POST") @@ -319,6 +320,65 @@ func createCommand(c *Context, w http.ResponseWriter, r *http.Request) { } } +func updateCommand(c *Context, w http.ResponseWriter, r *http.Request) { + if !*utils.Cfg.ServiceSettings.EnableCommands { + c.Err = model.NewLocAppError("updateCommand", "api.command.disabled.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + if !HasPermissionToCurrentTeamContext(c, model.PERMISSION_MANAGE_SLASH_COMMANDS) { + c.Err = model.NewLocAppError("updateCommand", "api.command.admin_only.app_error", nil, "") + c.Err.StatusCode = http.StatusForbidden + return + } + + c.LogAudit("attempt") + + cmd := model.CommandFromJson(r.Body) + + if cmd == nil { + c.SetInvalidParam("updateCommand", "command") + return + } + + cmd.Trigger = strings.ToLower(cmd.Trigger) + + var oldCmd *model.Command + if result := <-Srv.Store.Command().Get(cmd.Id); result.Err != nil { + c.Err = result.Err + return + } else { + oldCmd = result.Data.(*model.Command) + + if c.Session.UserId != oldCmd.CreatorId && !HasPermissionToCurrentTeamContext(c, model.PERMISSION_MANAGE_OTHERS_SLASH_COMMANDS) { + c.LogAudit("fail - inappropriate permissions") + c.Err = model.NewLocAppError("updateCommand", "api.command.update.app_error", nil, "user_id="+c.Session.UserId) + return + } + + if c.TeamId != oldCmd.TeamId { + c.Err = model.NewLocAppError("updateCommand", "api.command.team_mismatch.app_error", nil, "user_id="+c.Session.UserId) + return + } + + cmd.Id = oldCmd.Id + cmd.Token = oldCmd.Token + cmd.CreateAt = oldCmd.CreateAt + cmd.UpdateAt = model.GetMillis() + cmd.DeleteAt = oldCmd.DeleteAt + cmd.CreatorId = oldCmd.CreatorId + cmd.TeamId = oldCmd.TeamId + } + + if result := <-Srv.Store.Command().Update(cmd); result.Err != nil { + c.Err = result.Err + return + } else { + w.Write([]byte(result.Data.(*model.Command).ToJson())) + } +} + func listTeamCommands(c *Context, w http.ResponseWriter, r *http.Request) { if !*utils.Cfg.ServiceSettings.EnableCommands { c.Err = model.NewLocAppError("listTeamCommands", "api.command.disabled.app_error", nil, "") diff --git a/api/command_test.go b/api/command_test.go index 7a78d350d..45268a9a5 100644 --- a/api/command_test.go +++ b/api/command_test.go @@ -120,6 +120,45 @@ func TestListTeamCommands(t *testing.T) { } } +func TestUpdateCommand(t *testing.T) { + th := Setup().InitSystemAdmin() + Client := th.SystemAdminClient + user := th.SystemAdminUser + team := th.SystemAdminTeam + + enableCommands := *utils.Cfg.ServiceSettings.EnableCommands + defer func() { + utils.Cfg.ServiceSettings.EnableCommands = &enableCommands + }() + *utils.Cfg.ServiceSettings.EnableCommands = true + + cmd1 := &model.Command{ + CreatorId: user.Id, + TeamId: team.Id, + URL: "http://nowhere.com", + Method: model.COMMAND_METHOD_POST, + Trigger: "trigger"} + + cmd1 = Client.Must(Client.CreateCommand(cmd1)).Data.(*model.Command) + + cmd2 := &model.Command{ + CreatorId: user.Id, + TeamId: team.Id, + URL: "http://nowhere.com", + Method: model.COMMAND_METHOD_POST, + Trigger: "trigger2", + Token: cmd1.Token, + Id: cmd1.Id} + + if result, err := Client.UpdateCommand(cmd2); err != nil { + t.Fatal(err) + } else { + if result.Data.(*model.Command).Trigger == cmd1.Trigger { + t.Fatal("update didn't work properly") + } + } +} + func TestRegenToken(t *testing.T) { th := Setup().InitSystemAdmin() Client := th.SystemAdminClient diff --git a/i18n/en.json b/i18n/en.json index 4d5ccd525..a5a1e5928 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -439,6 +439,14 @@ "id": "api.command.regen.app_error", "translation": "Inappropriate permissions to regenerate command token" }, + { + "id": "api.command.team_mismatch.app_error", + "translation": "Cannot update commands across teams" + }, + { + "id": "api.command.update.app_error", + "translation": "Inappropriate permissions to update command" + }, { "id": "api.command_away.desc", "translation": "Set your status away" diff --git a/model/client.go b/model/client.go index 8a361c177..1624dc917 100644 --- a/model/client.go +++ b/model/client.go @@ -846,6 +846,16 @@ func (c *Client) CreateCommand(cmd *Command) (*Result, *AppError) { } } +func (c *Client) UpdateCommand(cmd *Command) (*Result, *AppError) { + if r, err := c.DoApiPost(c.GetTeamRoute()+"/commands/update", cmd.ToJson()); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), CommandFromJson(r.Body)}, nil + } +} + func (c *Client) RegenCommandToken(data map[string]string) (*Result, *AppError) { if r, err := c.DoApiPost(c.GetTeamRoute()+"/commands/regen_token", MapToJson(data)); err != nil { return nil, err diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx index 3ce6977f6..90ff75059 100644 --- a/webapp/client/client.jsx +++ b/webapp/client/client.jsx @@ -1457,6 +1457,18 @@ export default class Client { this.track('api', 'api_integrations_created'); } + editCommand(command, success, error) { + request. + post(`${this.getCommandsRoute()}/update`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(command). + end(this.handleResponse.bind(this, 'editCommand', success, error)); + + this.track('api', 'api_integrations_created'); + } + deleteCommand(commandId, success, error) { request. post(`${this.getCommandsRoute()}/delete`). diff --git a/webapp/components/integrations/components/edit_command.jsx b/webapp/components/integrations/components/edit_command.jsx new file mode 100644 index 000000000..395c977ca --- /dev/null +++ b/webapp/components/integrations/components/edit_command.jsx @@ -0,0 +1,731 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import * as AsyncClient from 'utils/async_client.jsx'; +import IntegrationStore from 'stores/integration_store.jsx'; +import TeamStore from 'stores/team_store.jsx'; +import * as Utils from 'utils/utils.jsx'; + +import {loadTeamCommands} from 'actions/integration_actions.jsx'; +import BackstageHeader from 'components/backstage/components/backstage_header.jsx'; +import {FormattedMessage} from 'react-intl'; +import FormError from 'components/form_error.jsx'; +import {browserHistory, Link} from 'react-router/es6'; +import SpinnerButton from 'components/spinner_button.jsx'; +import Constants from 'utils/constants.jsx'; +import ConfirmModal from 'components/confirm_modal.jsx'; + +const REQUEST_POST = 'P'; +const REQUEST_GET = 'G'; + +export default class EditCommand extends React.Component { + static get propTypes() { + return { + team: React.propTypes.object.isRequired, + location: React.PropTypes.object + }; + } + + constructor(props) { + super(props); + + this.handleIntegrationChange = this.handleIntegrationChange.bind(this); + + this.submitCommand = this.submitCommand.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleUpdate = this.handleUpdate.bind(this); + this.handleConfirmModal = this.handleConfirmModal.bind(this); + this.confirmModalDismissed = this.confirmModalDismissed.bind(this); + + this.updateDisplayName = this.updateDisplayName.bind(this); + this.updateDescription = this.updateDescription.bind(this); + this.updateTrigger = this.updateTrigger.bind(this); + this.updateUrl = this.updateUrl.bind(this); + this.updateMethod = this.updateMethod.bind(this); + this.updateUsername = this.updateUsername.bind(this); + this.updateIconUrl = this.updateIconUrl.bind(this); + this.updateAutocomplete = this.updateAutocomplete.bind(this); + this.updateAutocompleteHint = this.updateAutocompleteHint.bind(this); + this.updateAutocompleteDescription = this.updateAutocompleteDescription.bind(this); + + this.originalCommand = null; + this.newCommand = null; + + const teamId = TeamStore.getCurrentId(); + + this.state = { + displayName: '', + description: '', + trigger: '', + url: '', + method: REQUEST_POST, + username: '', + iconUrl: '', + autocomplete: false, + autocompleteHint: '', + autocompleteDescription: '', + saving: false, + serverError: '', + clientError: null, + showConfirmModal: false, + commands: IntegrationStore.getCommands(teamId), + loading: !IntegrationStore.hasReceivedCommands(teamId) + }; + } + + componentDidMount() { + IntegrationStore.addChangeListener(this.handleIntegrationChange); + + if (window.mm_config.EnableCommands === 'true') { + loadTeamCommands(); + } + } + + componentWillUnmount() { + IntegrationStore.removeChangeListener(this.handleIntegrationChange); + } + + handleConfirmModal() { + this.setState({showConfirmModal: true}); + } + + confirmModalDismissed() { + this.setState({showConfirmModal: false}); + } + + submitCommand() { + AsyncClient.editCommand( + this.newCmd, + browserHistory.push('/' + this.props.team.name + '/integrations/commands'), + (err) => { + this.setState({ + saving: false, + serverError: err.message + }); + } + ); + } + + handleUpdate() { + this.setState({ + saving: true, + serverError: '', + clientError: '' + }); + + this.submitCommand(); + } + + handleIntegrationChange() { + const teamId = TeamStore.getCurrentId(); + + this.setState({ + commands: IntegrationStore.getCommands(teamId), + loading: !IntegrationStore.hasReceivedCommands(teamId) + }); + + if (!this.state.loading) { + this.originalCommand = this.state.commands.filter((command) => command.id === this.props.location.query.id)[0]; + + this.setState({ + displayName: this.originalCommand.display_name, + description: this.originalCommand.description, + trigger: this.originalCommand.trigger, + url: this.originalCommand.url, + method: this.originalCommand.method, + username: this.originalCommand.username, + iconUrl: this.originalCommand.icon_url, + autocomplete: this.originalCommand.auto_complete, + autocompleteHint: this.originalCommand.auto_complete_hint, + autocompleteDescription: this.originalCommand.auto_complete_desc + }); + } + } + + handleSubmit(e) { + e.preventDefault(); + + if (this.state.saving) { + return; + } + + this.setState({ + saving: true, + serverError: '', + clientError: '' + }); + + let triggerWord = this.state.trigger.trim().toLowerCase(); + if (triggerWord.indexOf('/') === 0) { + triggerWord = triggerWord.substr(1); + } + + const command = { + display_name: this.state.displayName, + description: this.state.description, + trigger: triggerWord, + url: this.state.url.trim(), + method: this.state.method, + username: this.state.username, + icon_url: this.state.iconUrl, + auto_complete: this.state.autocomplete + }; + + if (this.originalCommand.id) { + command.id = this.originalCommand.id; + } + + if (command.auto_complete) { + command.auto_complete_desc = this.state.autocompleteDescription; + command.auto_complete_hint = this.state.autocompleteHint; + } + + if (!command.trigger) { + this.setState({ + saving: false, + clientError: ( + + ) + }); + + return; + } + + if (command.trigger.indexOf('/') === 0) { + this.setState({ + saving: false, + clientError: ( + + ) + }); + + return; + } + + if (command.trigger.indexOf(' ') !== -1) { + this.setState({ + saving: false, + clientError: ( + + ) + }); + return; + } + + if (command.trigger.length < Constants.MIN_TRIGGER_LENGTH || command.trigger.length > Constants.MAX_TRIGGER_LENGTH) { + this.setState({ + saving: false, + clientError: ( + + ) + }); + + return; + } + + if (!command.url) { + this.setState({ + saving: false, + clientError: ( + + ) + }); + + return; + } + + this.newCmd = command; + + if (this.originalCommand.url !== this.newCmd.url || this.originalCommand.trigger !== this.newCmd.trigger || this.originalCommand.method !== this.newCmd.method) { + this.handleConfirmModal(); + this.setState({ + saving: false + }); + } else { + this.submitCommand(); + } + } + + updateDisplayName(e) { + this.setState({ + displayName: e.target.value + }); + } + + updateDescription(e) { + this.setState({ + description: e.target.value + }); + } + + updateTrigger(e) { + this.setState({ + trigger: e.target.value + }); + } + + updateUrl(e) { + this.setState({ + url: e.target.value + }); + } + + updateMethod(e) { + this.setState({ + method: e.target.value + }); + } + + updateUsername(e) { + this.setState({ + username: e.target.value + }); + } + + updateIconUrl(e) { + this.setState({ + iconUrl: e.target.value + }); + } + + updateAutocomplete(e) { + this.setState({ + autocomplete: e.target.checked + }); + } + + updateAutocompleteHint(e) { + this.setState({ + autocompleteHint: e.target.value + }); + } + + updateAutocompleteDescription(e) { + this.setState({ + autocompleteDescription: e.target.value + }); + } + + render() { + const confirmButton = ( + + ); + + const confirmTitle = ( + + ); + + const confirmMessage = ( + + ); + + let autocompleteFields = null; + if (this.state.autocomplete) { + autocompleteFields = [( +
+ +
+ +
+ +
+
+
+ ), + ( +
+ +
+ +
+ +
+
+
+ )]; + } + + return ( +
+ + + + + + +
+
+
+ +
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+ +
+
+ + + + ) + }} + /> +
+
+
+
+ +
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+
+ {autocompleteFields} +
+ + + + + + + + +
+
+
+
+ ); + } +} diff --git a/webapp/components/integrations/components/installed_command.jsx b/webapp/components/integrations/components/installed_command.jsx index f149a21ac..ecd7d9608 100644 --- a/webapp/components/integrations/components/installed_command.jsx +++ b/webapp/components/integrations/components/installed_command.jsx @@ -129,6 +129,15 @@ export default class InstalledCommand extends React.Component { /> {' - '} + + + + {' - '} { + System.import('components/integrations/components/edit_command.jsx').then(RouteUtils.importComponentSuccess(callback)); + } + }, { path: 'confirm', getComponents: (location, callback) => { diff --git a/webapp/stores/integration_store.jsx b/webapp/stores/integration_store.jsx index 33680452b..ae818b443 100644 --- a/webapp/stores/integration_store.jsx +++ b/webapp/stores/integration_store.jsx @@ -137,6 +137,15 @@ class IntegrationStore extends EventEmitter { this.setCommands(teamId, commands); } + editCommand(command) { + const teamId = command.team_id; + const commands = this.getCommands(teamId); + + commands.push(command); + + this.setCommands(teamId, commands); + } + updateCommand(command) { const teamId = command.team_id; const commands = this.getCommands(teamId); diff --git a/webapp/tests/client_command.test.jsx b/webapp/tests/client_command.test.jsx index 769fa2fa0..7d39537f8 100644 --- a/webapp/tests/client_command.test.jsx +++ b/webapp/tests/client_command.test.jsx @@ -81,6 +81,34 @@ describe('Client.Commands', function() { }); }); + it('editCommand', function(done) { + TestHelper.initBasic(() => { + TestHelper.basicClient().enableLogErrorsToConsole(false); // Disabling since this unit test causes an error + + var cmd = {}; + cmd.url = 'http://www.gonowhere.com'; + cmd.trigger = '/hello'; + cmd.method = 'P'; + cmd.username = ''; + cmd.icon_url = ''; + cmd.auto_complete = false; + cmd.auto_complete_desc = ''; + cmd.auto_complete_hint = ''; + cmd.display_name = 'Unit Test'; + + TestHelper.basicClient().editCommand( + cmd, + function() { + done(new Error('cmds not enabled')); + }, + function(err) { + assert.equal(err.id, 'api.command.disabled.app_error'); + done(); + } + ); + }); + }); + it('deleteCommand', function(done) { TestHelper.initBasic(() => { TestHelper.basicClient().enableLogErrorsToConsole(false); // Disabling since this unit test causes an error diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx index efa9eeb2b..fe31d4ef8 100644 --- a/webapp/utils/async_client.jsx +++ b/webapp/utils/async_client.jsx @@ -1348,6 +1348,29 @@ export function addCommand(command, success, error) { ); } +export function editCommand(command, success, error) { + Client.editCommand( + command, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_COMMAND, + command: data + }); + + if (success) { + success(data); + } + }, + (err) => { + if (error) { + error(err); + } else { + dispatchError(err, 'editCommand'); + } + } + ); +} + export function deleteCommand(id) { Client.deleteCommand( id, -- cgit v1.2.3-1-g7c22