Skip to content

Commit

Permalink
MM-14341 Add client-side messageWillBePosted hook (mattermost#2526)
Browse files Browse the repository at this point in the history
* MM-14341 Add client-side messageWillBePosted hook

* Fix and add another test

* Update CreatePost unit tests

* Handle falsey results from MessageWillBePosted hooks
  • Loading branch information
hmhealey committed Mar 25, 2019
1 parent 194cf95 commit 9681600
Show file tree
Hide file tree
Showing 8 changed files with 303 additions and 42 deletions.
29 changes: 29 additions & 0 deletions actions/hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

export function runMessageWillBePostedHooks(originalPost) {
return async (dispatch, getState) => {
const hooks = getState().plugins.components.MessageWillBePosted;
if (!hooks || hooks.length === 0) {
return {data: originalPost};
}

let post = originalPost;

for (const hook of hooks) {
const result = await hook.hook(post); // eslint-disable-line no-await-in-loop

if (result) {
if (result.error) {
return {
error: result.error,
};
}

post = result.post;
}
}

return {data: post};
};
}
158 changes: 158 additions & 0 deletions actions/hooks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';

import {runMessageWillBePostedHooks} from './hooks';

const mockStore = configureStore([thunk]);

describe('runMessageWillBePostedHooks', () => {
test('should do nothing when no hooks are registered', async () => {
const store = mockStore({
plugins: {
components: {},
},
});
const post = {message: 'test'};

const result = await store.dispatch(runMessageWillBePostedHooks(post));

expect(result).toEqual({data: post});
});

test('should pass the post through every hook', async () => {
const hook1 = jest.fn((post) => ({post}));
const hook2 = jest.fn((post) => ({post}));
const hook3 = jest.fn((post) => ({post}));

const store = mockStore({
plugins: {
components: {
MessageWillBePosted: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const post = {message: 'test'};

const result = await store.dispatch(runMessageWillBePostedHooks(post));

expect(result).toEqual({data: post});
expect(hook1).toHaveBeenCalledWith(post);
expect(hook2).toHaveBeenCalledWith(post);
expect(hook3).toHaveBeenCalledWith(post);
});

test('should return an error when a hook rejects the post', async () => {
const hook1 = jest.fn((post) => ({post}));
const hook2 = jest.fn(() => ({error: {message: 'an error occurred'}}));
const hook3 = jest.fn((post) => ({post}));

const store = mockStore({
plugins: {
components: {
MessageWillBePosted: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const post = {message: 'test'};

const result = await store.dispatch(runMessageWillBePostedHooks(post));

expect(result).toEqual({error: {message: 'an error occurred'}});
expect(hook1).toHaveBeenCalledWith(post);
expect(hook2).toHaveBeenCalledWith(post);
expect(hook3).not.toHaveBeenCalled();
});

test('should pass the result of each hook to the next', async () => {
const hook1 = jest.fn((post) => ({post: {...post, message: post.message + 'a'}}));
const hook2 = jest.fn((post) => ({post: {...post, message: post.message + 'b'}}));
const hook3 = jest.fn((post) => ({post: {...post, message: post.message + 'c'}}));

const store = mockStore({
plugins: {
components: {
MessageWillBePosted: [
{hook: hook1},
{hook: hook2},
{hook: hook3},
],
},
},
});
const post = {message: 'test'};

const result = await store.dispatch(runMessageWillBePostedHooks(post));

expect(result).toEqual({data: {message: 'testabc'}});
expect(hook1).toHaveBeenCalledWith(post);
expect(hook2).toHaveBeenCalled();
expect(hook2).not.toHaveBeenCalledWith(post);
expect(hook3).toHaveBeenCalled();
expect(hook3).not.toHaveBeenCalledWith(post);
});

test('should wait for async hooks', async () => {
jest.useFakeTimers();

const hook = jest.fn((post) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({post: {...post, message: post.message + 'async'}});
}, 100);

jest.runAllTimers();
});
});

const store = mockStore({
plugins: {
components: {
MessageWillBePosted: [
{hook},
],
},
},
});
const post = {message: 'test'};

const result = await store.dispatch(runMessageWillBePostedHooks(post));

expect(result).toEqual({data: {message: 'testasync'}});
expect(hook).toHaveBeenCalledWith(post);
});

test('should assume post is unchanged if a hook returns undefined', async () => {
const hook1 = jest.fn();
const hook2 = jest.fn((post) => ({post: {...post, message: post.message + 'b'}}));

const store = mockStore({
plugins: {
components: {
MessageWillBePosted: [
{hook: hook1},
{hook: hook2},
],
},
},
});
const post = {message: 'test'};

const result = await store.dispatch(runMessageWillBePostedHooks(post));

expect(result).toEqual({data: {message: 'testb'}});
expect(hook1).toHaveBeenCalledWith(post);
expect(hook2).toHaveBeenCalled();
expect(hook2).toHaveBeenCalledWith(post);
});
});
16 changes: 12 additions & 4 deletions actions/views/create_comment.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {isPostPendingOrFailed} from 'mattermost-redux/utils/post_utils';

import * as PostActions from 'actions/post_actions.jsx';
import {executeCommand} from 'actions/command';
import {runMessageWillBePostedHooks} from 'actions/hooks';
import {setGlobalItem, actionOnGlobalItemsWithPrefix} from 'actions/storage';
import EmojiMap from 'utils/emoji_map';
import {getPostDraft} from 'selectors/rhs';
Expand Down Expand Up @@ -59,14 +60,14 @@ export function makeOnMoveHistoryIndex(rootId, direction) {
}

export function submitPost(channelId, rootId, draft) {
return (dispatch, getState) => {
return async (dispatch, getState) => {
const state = getState();

const userId = getCurrentUserId(state);

const time = Utils.getTimestamp();

const post = {
let post = {
file_ids: [],
message: draft.message,
channel_id: channelId,
Expand All @@ -77,7 +78,14 @@ export function submitPost(channelId, rootId, draft) {
create_at: time,
};

dispatch(PostActions.createPost(post, draft.fileInfos));
const hookResult = await dispatch(runMessageWillBePostedHooks(post));
if (hookResult.error) {
return {error: hookResult.error};
}

post = hookResult.data;

return dispatch(PostActions.createPost(post, draft.fileInfos));
};
}

Expand Down Expand Up @@ -110,7 +118,7 @@ export function submitCommand(channelId, rootId, draft) {

if (error) {
if (error.sendMessage) {
dispatch(submitPost(channelId, rootId, draft));
await dispatch(submitPost(channelId, rootId, draft));
} else {
throw (error);
}
Expand Down
27 changes: 21 additions & 6 deletions actions/views/create_comment.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import {setGlobalItem, actionOnGlobalItemsWithPrefix} from 'actions/storage';
import * as PostActions from 'actions/post_actions.jsx';
import {executeCommand} from 'actions/command';
import * as HookActions from 'actions/hooks';
import {StoragePrefixes} from 'utils/constants';

/* eslint-disable global-require */
Expand All @@ -49,6 +50,10 @@ jest.mock('actions/global_actions.jsx', () => ({
emitUserCommentedEvent: jest.fn(),
}));

jest.mock('actions/hooks', () => ({
runMessageWillBePostedHooks: jest.fn((post) => () => ({data: post})),
}));

jest.mock('actions/post_actions.jsx', () => ({
addReaction: (...args) => ({type: 'MOCK_ADD_REACTION', args}),
createPost: jest.fn(() => ({type: 'MOCK_CREATE_POST'})),
Expand Down Expand Up @@ -213,9 +218,10 @@ describe('rhs view actions', () => {
user_id: currentUserId,
};

test('it call PostActions.createPost with post', () => {
store.dispatch(submitPost(channelId, rootId, draft));
test('it call PostActions.createPost with post', async () => {
await store.dispatch(submitPost(channelId, rootId, draft));

expect(HookActions.runMessageWillBePostedHooks).toHaveBeenCalled();
expect(PostActions.createPost).toHaveBeenCalled();

expect(lastCall(PostActions.createPost.mock.calls)[0]).toEqual(
Expand All @@ -224,6 +230,15 @@ describe('rhs view actions', () => {

expect(lastCall(PostActions.createPost.mock.calls)[1]).toBe(draft.fileInfos);
});

test('it does not call PostActions.createPost when hooks fail', async () => {
HookActions.runMessageWillBePostedHooks.mockImplementation(() => () => ({error: {message: 'An error occurred'}}));

await store.dispatch(submitPost(channelId, rootId, draft));

expect(HookActions.runMessageWillBePostedHooks).toHaveBeenCalled();
expect(PostActions.createPost).not.toHaveBeenCalled();
});
});

describe('submitReaction', () => {
Expand Down Expand Up @@ -255,8 +270,8 @@ describe('rhs view actions', () => {

const draft = {message: 'test msg'};

test('it calls executeCommand', () => {
store.dispatch(submitCommand(channelId, rootId, draft));
test('it calls executeCommand', async () => {
await store.dispatch(submitCommand(channelId, rootId, draft));

expect(executeCommand).toHaveBeenCalled();

Expand All @@ -267,7 +282,7 @@ describe('rhs view actions', () => {
expect(lastCall(executeCommand.mock.calls)[1]).toEqual(args);
});

test('it calls submitPost on error.sendMessage', () => {
test('it calls submitPost on error.sendMessage', async () => {
jest.mock('actions/channel_actions.jsx', () => ({
executeCommand: jest.fn((message, _args, resolve, reject) => reject({sendMessage: 'test'})),
}));
Expand All @@ -276,7 +291,7 @@ describe('rhs view actions', () => {

const {submitCommand: remockedSubmitCommand} = require('actions/views/create_comment');

store.dispatch(remockedSubmitCommand(channelId, rootId, draft));
await store.dispatch(remockedSubmitCommand(channelId, rootId, draft));

const expectedActions = [{args: ['test msg', {channel_id: '4j5j4k3k34j4', parent_id: 'fc234c34c23', root_id: 'fc234c34c23', team_id: '4j5nmn4j3'}], type: 'MOCK_ACTIONS_COMMAND_EXECUTE'}];
expect(store.getActions()).toEqual(expectedActions);
Expand Down
Loading

0 comments on commit 9681600

Please sign in to comment.