Commit 4453f86f authored by Michael Kochell's avatar Michael Kochell Committed by Sudheer

[MM-8338] Option to send as message when an invalid slash command is entered (#1969)

* general feature complete. changed functionality is scoped to create_post.jsx

* textbox now keeps focus after slash command error. submitting twice will force submit the message.

update create_post test

* move "message submit error" into its own component

* handle server error type correctly, and fix some i18n references

* move tests to be next to source code

* fix nested `FormattedMessage` components
parent 24e026ce
......@@ -10,7 +10,7 @@ import {sortFileInfos} from 'mattermost-redux/utils/file_utils';
import * as GlobalActions from 'actions/global_actions.jsx';
import Constants, {StoragePrefixes, ModalIdentifiers} from 'utils/constants.jsx';
import {containsAtChannel, postMessageOnKeyPress, shouldFocusMainTextbox} from 'utils/post_utils.jsx';
import {containsAtChannel, postMessageOnKeyPress, shouldFocusMainTextbox, isErrorInvalidSlashCommand} from 'utils/post_utils.jsx';
import * as UserAgent from 'utils/user_agent.jsx';
import * as Utils from 'utils/utils.jsx';
......@@ -27,6 +27,7 @@ import Textbox from 'components/textbox.jsx';
import TutorialTip from 'components/tutorial/tutorial_tip';
import FormattedMarkdownMessage from 'components/formatted_markdown_message.jsx';
import MessageSubmitError from 'components/message_submit_error';
const KeyCodes = Constants.KeyCodes;
......@@ -311,9 +312,18 @@ export default class CreatePost extends React.Component {
return;
}
let message = this.state.message;
let ignoreSlash = false;
const serverError = this.state.serverError;
if (serverError && isErrorInvalidSlashCommand(serverError) && serverError.submittedMessage === message) {
message = serverError.submittedMessage;
ignoreSlash = true;
}
const post = {};
post.file_ids = [];
post.message = this.state.message;
post.message = message;
if (post.message.trim().length === 0 && this.props.draft.fileInfos.length === 0) {
return;
......@@ -332,7 +342,7 @@ export default class CreatePost extends React.Component {
this.setState({submitting: true, serverError: null});
const isReaction = Utils.REACTION_PATTERN.exec(post.message);
if (post.message.indexOf('/') === 0) {
if (post.message.indexOf('/') === 0 && !ignoreSlash) {
this.setState({message: '', postError: null, enableSendButton: false});
const args = {};
args.channel_id = channelId;
......@@ -345,7 +355,10 @@ export default class CreatePost extends React.Component {
this.sendMessage(post);
} else {
this.setState({
serverError: error.message,
serverError: {
...error,
submittedMessage: post.message,
},
message: post.message,
});
}
......@@ -540,9 +553,16 @@ export default class CreatePost extends React.Component {
const message = e.target.value;
const channelId = this.props.currentChannel.id;
const enableSendButton = this.handleEnableSendButton(message, this.props.draft.fileInfos);
let serverError = this.state.serverError;
if (isErrorInvalidSlashCommand(serverError)) {
serverError = null;
}
this.setState({
message,
enableSendButton,
serverError,
});
const draft = {
......@@ -602,10 +622,9 @@ export default class CreatePost extends React.Component {
handleUploadError = (err, clientId, channelId) => {
const draft = {...this.draftsForChannel[channelId]};
let message = err;
if (message && typeof message !== 'string') {
// err is an AppError from the server
message = err.message;
let serverError = err;
if (typeof err === 'string') {
serverError = new Error(err);
}
if (clientId !== -1 && draft.uploadsInProgress) {
......@@ -622,7 +641,7 @@ export default class CreatePost extends React.Component {
}
}
this.setState({serverError: message});
this.setState({serverError});
}
removePreview = (id) => {
......@@ -926,9 +945,12 @@ export default class CreatePost extends React.Component {
let serverError = null;
if (this.state.serverError) {
serverError = (
<div className='has-error'>
<label className='control-label'>{this.state.serverError}</label>
</div>
<MessageSubmitError
id='postServerError'
error={this.state.serverError}
submittedMessage={this.state.serverError.submittedMessage}
handleSubmit={this.handleSubmit}
/>
);
}
......
......@@ -755,4 +755,86 @@ describe('components/create_post', () => {
const wrapper = shallow(createPost({canUploadFiles: false}));
expect(wrapper).toMatchSnapshot();
});
it('should allow to force send invalid slash command as a message', async () => {
const error = {
message: 'No command found',
server_error_id: 'api.command.execute_command.not_found.app_error',
};
const executeCommand = jest.fn(() => Promise.resolve({error}));
const onSubmitPost = jest.fn();
const wrapper = shallow(
createPost({
actions: {
...actionsProp,
executeCommand,
onSubmitPost,
},
})
);
wrapper.setState({
message: '/fakecommand some text',
});
expect(wrapper.find('[id="postServerError"]').exists()).toBe(false);
await wrapper.instance().handleSubmit({preventDefault: jest.fn()});
expect(executeCommand).toHaveBeenCalled();
expect(wrapper.find('[id="postServerError"]').exists()).toBe(true);
expect(onSubmitPost).not.toHaveBeenCalled();
await wrapper.instance().handleSubmit({preventDefault: jest.fn()});
expect(wrapper.find('[id="postServerError"]').exists()).toBe(false);
expect(onSubmitPost).toHaveBeenCalledWith(
expect.objectContaining({
message: '/fakecommand some text',
}),
expect.anything(),
);
});
it('should throw away invalid command error if user resumes typing', async () => {
const error = {
message: 'No command found',
server_error_id: 'api.command.execute_command.not_found.app_error',
};
const executeCommand = jest.fn(() => Promise.resolve({error}));
const onSubmitPost = jest.fn();
const wrapper = shallow(
createPost({
actions: {
...actionsProp,
executeCommand,
onSubmitPost,
},
})
);
wrapper.setState({
message: '/fakecommand some text',
});
expect(wrapper.find('[id="postServerError"]').exists()).toBe(false);
await wrapper.instance().handleSubmit({preventDefault: jest.fn()});
expect(executeCommand).toHaveBeenCalled();
expect(wrapper.find('[id="postServerError"]').exists()).toBe(true);
expect(onSubmitPost).not.toHaveBeenCalled();
wrapper.instance().handleChange({
target: {value: 'some valid text'},
});
expect(wrapper.find('[id="postServerError"]').exists()).toBe(false);
wrapper.instance().handleSubmit({preventDefault: jest.fn()});
expect(onSubmitPost).toHaveBeenCalledWith(
expect.objectContaining({
message: 'some valid text',
}),
expect.anything(),
);
});
});
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {isErrorInvalidSlashCommand} from 'utils/post_utils.jsx';
class MessageSubmitError extends React.PureComponent {
static propTypes = {
error: PropTypes.object.isRequired,
handleSubmit: PropTypes.func.isRequired,
submittedMessage: PropTypes.string,
}
renderSlashCommandError = () => {
if (!this.props.submittedMessage) {
return this.props.error.message;
}
const command = this.props.submittedMessage.split(' ')[0];
return (
<React.Fragment>
<FormattedMessage
id='message_submit_error.invalidCommand'
defaultMessage={'Command with a trigger of \'{command}\' not found. '}
values={{
command,
}}
/>
<a
href='#'
onClick={this.props.handleSubmit}
>
<FormattedMessage
id='message-submit-error.sendAsMessageLink'
defaultMessage='Click here to send as a message.'
/>
</a>
</React.Fragment>
);
}
render() {
const error = this.props.error;
if (!error) {
return null;
}
let errorContent = error.message;
if (isErrorInvalidSlashCommand(error)) {
errorContent = this.renderSlashCommandError();
}
return (
<div className='has-error'>
<label className='control-label'>
{errorContent}
</label>
</div>
);
}
}
export default MessageSubmitError;
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import MessageSubmitError from 'components/message_submit_error.jsx';
describe('components/MessageSubmitError', () => {
const baseProps = {
handleSubmit: jest.fn(),
};
it('should display the submit link if the error is for an invalid slash command', () => {
const error = {
message: 'No command found',
server_error_id: 'api.command.execute_command.not_found.app_error',
};
const submittedMessage = 'fakecommand some text';
const props = {
...baseProps,
error,
submittedMessage,
};
const wrapper = shallow(
<MessageSubmitError {...props}/>
);
expect(wrapper.find('[id="message_submit_error.invalidCommand"]').exists()).toBe(true);
expect(wrapper.text()).not.toEqual('No command found');
});
it('should not display the submit link if the error is not for an invalid slash command', () => {
const error = {
message: 'Some server error',
server_error_id: 'api.other_error',
};
const submittedMessage = '/fakecommand some text';
const props = {
...baseProps,
error,
submittedMessage,
};
const wrapper = shallow(
<MessageSubmitError {...props}/>
);
expect(wrapper.find('[id="message_submit_error.invalidCommand"]').exists()).toBe(false);
expect(wrapper.text()).toEqual('Some server error');
});
});
......@@ -2165,6 +2165,8 @@
"members_popover.manageMembers": "Manage Members",
"members_popover.title": "Channel Members",
"members_popover.viewMembers": "View Members",
"message_submit_error.sendAsMessageLink": "Click here to send as a message.",
"message_submit_error.invalidCommand": "Command with a trigger of '{command}' not found. ",
"mfa.confirm.complete": "**Set up complete!**",
"mfa.confirm.okay": "Okay",
"mfa.confirm.secure": "Your account is now secure. Next time you sign in, you will be asked to enter a code from the Google Authenticator app on your phone.",
......
......@@ -232,3 +232,11 @@ export function postMessageOnKeyPress(event, message, sendMessageOnCtrlEnter, se
return {allowSending: false};
}
export function isErrorInvalidSlashCommand(error) {
if (error && error.server_error_id) {
return error.server_error_id === 'api.command.execute_command.not_found.app_error';
}
return false;
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment