MM-59881 Bubble submit result and expose to plugins (#27766)

Automatic Merge
This commit is contained in:
Daniel Espino García 2024-08-07 21:27:50 +02:00 committed by GitHub
parent ed2d838ac7
commit 6027c850bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 117 additions and 205 deletions

View File

@ -183,7 +183,7 @@ describe('executeCommand', () => {
modalId: ModalIdentifiers.KEYBOARD_SHORTCUTS_MODAL, modalId: ModalIdentifiers.KEYBOARD_SHORTCUTS_MODAL,
}); });
expect(result).toEqual({data: true}); expect(result.data).toBeDefined();
}); });
}); });
@ -198,7 +198,7 @@ describe('executeCommand', () => {
modalId: 'user_settings', modalId: 'user_settings',
}, },
]); ]);
expect(result).toEqual({data: true}); expect(result.data).toBeDefined();
}); });
}); });
@ -220,7 +220,7 @@ describe('executeCommand', () => {
toHaveBeenCalledWith('/leave is not supported in reply threads. Use it in the center channel instead.', toHaveBeenCalledWith('/leave is not supported in reply threads. Use it in the center channel instead.',
'channel_id', 'root_id'); 'channel_id', 'root_id');
expect(result).toEqual({data: true}); expect(result.data).toBeDefined();
}); });
test('should show private modal if channel is private', async () => { test('should show private modal if channel is private', async () => {
@ -236,7 +236,7 @@ describe('executeCommand', () => {
dialogProps: {channel: {type: Constants.PRIVATE_CHANNEL}}, dialogProps: {channel: {type: Constants.PRIVATE_CHANNEL}},
}); });
expect(result).toEqual({data: true}); expect(result.data).toBeDefined();
}); });
test('should use user id as name if channel is dm', async () => { test('should use user id as name if channel is dm', async () => {
@ -248,7 +248,7 @@ describe('executeCommand', () => {
const result = await store.dispatch(executeCommand('/leave', {})); const result = await store.dispatch(executeCommand('/leave', {}));
expect(store.getActions()[0].data).toEqual([{category: 'direct_channel_show', name: 'userId', user_id: 'user123', value: 'false'}]); expect(store.getActions()[0].data).toEqual([{category: 'direct_channel_show', name: 'userId', user_id: 'user123', value: 'false'}]);
expect(result).toEqual({data: true}); expect(result.data).toBeDefined();
}); });
test('should use channel id as name if channel is gm', async () => { test('should use channel id as name if channel is gm', async () => {
@ -260,7 +260,7 @@ describe('executeCommand', () => {
const result = await store.dispatch(executeCommand('/leave', {})); const result = await store.dispatch(executeCommand('/leave', {}));
expect(store.getActions()[0].data).toEqual([{category: 'group_channel_show', name: 'channelId', user_id: 'user123', value: 'false'}]); expect(store.getActions()[0].data).toEqual([{category: 'group_channel_show', name: 'channelId', user_id: 'user123', value: 'false'}]);
expect(result).toEqual({data: true}); expect(result.data).toBeDefined();
}); });
}); });
@ -295,7 +295,7 @@ describe('executeCommand', () => {
type: ActionTypes.MODAL_OPEN, type: ActionTypes.MODAL_OPEN,
modalId: ModalIdentifiers.PLUGIN_MARKETPLACE, modalId: ModalIdentifiers.PLUGIN_MARKETPLACE,
}); });
expect(result).toEqual({data: true}); expect(result.data).toBeDefined();
}); });
test('should show error when marketpace is not enabled', async () => { test('should show error when marketpace is not enabled', async () => {
@ -395,7 +395,7 @@ describe('executeCommand', () => {
query: undefined, query: undefined,
selected_field: undefined, selected_field: undefined,
}, true); }, true);
expect(result).toEqual({data: true}); expect(result.data).toBeDefined();
}); });
}); });
}); });

View File

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type {CommandArgs} from '@mattermost/types/integrations'; import type {AppCallResponse} from '@mattermost/types/apps';
import type {CommandArgs, CommandResponse} from '@mattermost/types/integrations';
import {IntegrationTypes} from 'mattermost-redux/action_types'; import {IntegrationTypes} from 'mattermost-redux/action_types';
import {unfavoriteChannel} from 'mattermost-redux/actions/channels'; import {unfavoriteChannel} from 'mattermost-redux/actions/channels';
@ -39,7 +40,14 @@ import type {GlobalState} from 'types/store';
import {doAppSubmit, openAppsModal, postEphemeralCallResponseForCommandArgs} from './apps'; import {doAppSubmit, openAppsModal, postEphemeralCallResponseForCommandArgs} from './apps';
import {trackEvent} from './telemetry_actions'; import {trackEvent} from './telemetry_actions';
export function executeCommand(message: string, args: CommandArgs): ActionFuncAsync<boolean, GlobalState> { export type ExecuteCommandReturnType = {
frontendHandled?: boolean;
silentFailureReason?: Error;
commandResponse?: CommandResponse;
appResponse?: AppCallResponse;
}
export function executeCommand(message: string, args: CommandArgs): ActionFuncAsync<ExecuteCommandReturnType, GlobalState> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState() as GlobalState; const state = getState() as GlobalState;
@ -71,7 +79,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs
switch (cmd) { switch (cmd) {
case '/search': case '/search':
dispatch(PostActions.searchForTerm(msg.substring(cmdLength + 1, msg.length))); dispatch(PostActions.searchForTerm(msg.substring(cmdLength + 1, msg.length)));
return {data: true}; return {data: {frontendHandled: true}};
case '/shortcuts': case '/shortcuts':
if (UserAgent.isMobile()) { if (UserAgent.isMobile()) {
const error = {message: localizeMessage('create_post.shortcutsNotSupported', 'Keyboard shortcuts are not supported on your device')}; const error = {message: localizeMessage('create_post.shortcutsNotSupported', 'Keyboard shortcuts are not supported on your device')};
@ -79,20 +87,20 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs
} }
dispatch(openModal({modalId: ModalIdentifiers.KEYBOARD_SHORTCUTS_MODAL, dialogType: KeyboardShortcutsModal})); dispatch(openModal({modalId: ModalIdentifiers.KEYBOARD_SHORTCUTS_MODAL, dialogType: KeyboardShortcutsModal}));
return {data: true}; return {data: {frontendHandled: true}};
case '/leave': { case '/leave': {
// /leave command not supported in reply threads. // /leave command not supported in reply threads.
if (args.channel_id && args.root_id) { if (args.channel_id && args.root_id) {
dispatch(GlobalActions.sendEphemeralPost('/leave is not supported in reply threads. Use it in the center channel instead.', args.channel_id, args.root_id)); dispatch(GlobalActions.sendEphemeralPost('/leave is not supported in reply threads. Use it in the center channel instead.', args.channel_id, args.root_id));
return {data: true}; return {data: {frontendHandled: true}};
} }
const channel = getCurrentChannel(state); const channel = getCurrentChannel(state);
if (!channel) { if (!channel) {
return {data: false}; return {data: {silentFailureReason: new Error('cannot find current channel')}};
} }
if (channel.type === Constants.PRIVATE_CHANNEL) { if (channel.type === Constants.PRIVATE_CHANNEL) {
dispatch(openModal({modalId: ModalIdentifiers.LEAVE_PRIVATE_CHANNEL_MODAL, dialogType: LeaveChannelModal, dialogProps: {channel}})); dispatch(openModal({modalId: ModalIdentifiers.LEAVE_PRIVATE_CHANNEL_MODAL, dialogType: LeaveChannelModal, dialogProps: {channel}}));
return {data: true}; return {data: {frontendHandled: true}};
} }
if ( if (
channel.type === Constants.DM_CHANNEL || channel.type === Constants.DM_CHANNEL ||
@ -118,13 +126,13 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs
dispatch(unfavoriteChannel(channel.id)); dispatch(unfavoriteChannel(channel.id));
} }
return {data: true}; return {data: {frontendHandled: true}};
} }
break; break;
} }
case '/settings': case '/settings':
dispatch(openModal({modalId: ModalIdentifiers.USER_SETTINGS, dialogType: UserSettingsModal, dialogProps: {isContentProductSettings: true}})); dispatch(openModal({modalId: ModalIdentifiers.USER_SETTINGS, dialogType: UserSettingsModal, dialogProps: {isContentProductSettings: true}}));
return {data: true}; return {data: {frontendHandled: true}};
case '/marketplace': case '/marketplace':
// check if user has permissions to access the read plugins // check if user has permissions to access the read plugins
if (!haveICurrentTeamPermission(state, Permissions.SYSCONSOLE_WRITE_PLUGINS)) { if (!haveICurrentTeamPermission(state, Permissions.SYSCONSOLE_WRITE_PLUGINS)) {
@ -137,7 +145,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs
} }
dispatch(openModal({modalId: ModalIdentifiers.PLUGIN_MARKETPLACE, dialogType: MarketplaceModal, dialogProps: {openedFrom: 'command'}})); dispatch(openModal({modalId: ModalIdentifiers.PLUGIN_MARKETPLACE, dialogType: MarketplaceModal, dialogProps: {openedFrom: 'command'}}));
return {data: true}; return {data: {frontendHandled: true}};
case '/collapse': case '/collapse':
case '/expand': case '/expand':
dispatch(PostActions.resetEmbedVisibility()); dispatch(PostActions.resetEmbedVisibility());
@ -173,14 +181,14 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs
if (callResp.text) { if (callResp.text) {
dispatch(postEphemeralCallResponseForCommandArgs(callResp, callResp.text, args)); dispatch(postEphemeralCallResponseForCommandArgs(callResp, callResp.text, args));
} }
return {data: true}; return {data: {appResponse: callResp}};
case AppCallResponseTypes.FORM: case AppCallResponseTypes.FORM:
if (callResp.form) { if (callResp.form) {
dispatch(openAppsModal(callResp.form, creq.context)); dispatch(openAppsModal(callResp.form, creq.context));
} }
return {data: true}; return {data: {appResponse: callResp}};
case AppCallResponseTypes.NAVIGATE: case AppCallResponseTypes.NAVIGATE:
return {data: true}; return {data: {appResponse: callResp}};
default: default:
return createErrorMessage(intlShim.formatMessage( return createErrorMessage(intlShim.formatMessage(
{ {
@ -213,7 +221,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs
if (msg.trim() === '/logout') { if (msg.trim() === '/logout') {
GlobalActions.emitUserLoggedOutEvent(hasGotoLocation ? data.goto_location : '/'); GlobalActions.emitUserLoggedOutEvent(hasGotoLocation ? data.goto_location : '/');
return {data: true}; return {data: {response: data}};
} }
if (data.trigger_id) { if (data.trigger_id) {
@ -230,6 +238,6 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs
} }
} }
return {data: true}; return {data: {response: data}};
}; };
} }

View File

@ -358,7 +358,7 @@ describe('Actions.Posts', () => {
const immediateExpectedState = [{ const immediateExpectedState = [{
args: [newPost, files], args: [newPost, files],
type: 'MOCK_CREATE_POST_IMMEDIATELY', type: 'MOCK_CREATE_POST',
}, { }, {
args: ['draft_current_channel_id', null], args: ['draft_current_channel_id', null],
type: 'MOCK_SET_GLOBAL_ITEM', type: 'MOCK_SET_GLOBAL_ITEM',
@ -453,8 +453,8 @@ describe('Actions.Posts', () => {
testStore.dispatch(Actions.submitReaction('post_id_1', '+', 'emoji_name_1')); testStore.dispatch(Actions.submitReaction('post_id_1', '+', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([ expect(testStore.getActions()).toEqual([
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'},
{args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'},
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'},
]); ]);
}); });
@ -503,8 +503,8 @@ describe('Actions.Posts', () => {
testStore.dispatch(Actions.toggleReaction('post_id_1', 'emoji_name_1')); testStore.dispatch(Actions.toggleReaction('post_id_1', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([ expect(testStore.getActions()).toEqual([
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'},
{args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'},
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'},
]); ]);
}); });
@ -529,8 +529,8 @@ describe('Actions.Posts', () => {
await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1')); await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([ expect(testStore.getActions()).toEqual([
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'},
{args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'},
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'},
]); ]);
}); });
test('should not add reaction if we are over the limit', async () => { test('should not add reaction if we are over the limit', async () => {

View File

@ -40,12 +40,12 @@ import {
} from 'utils/constants'; } from 'utils/constants';
import {matchEmoticons} from 'utils/emoticons'; import {matchEmoticons} from 'utils/emoticons';
import {makeGetIsReactionAlreadyAddedToPost, makeGetUniqueEmojiNameReactionsForPost} from 'utils/post_utils'; import {makeGetIsReactionAlreadyAddedToPost, makeGetUniqueEmojiNameReactionsForPost} from 'utils/post_utils';
import * as UserAgent from 'utils/user_agent';
import type {GlobalState} from 'types/store'; import type {GlobalState} from 'types/store';
import {completePostReceive} from './new_post'; import {completePostReceive} from './new_post';
import type {NewPostMessageProps} from './new_post'; import type {NewPostMessageProps} from './new_post';
import type {SubmitPostReturnType} from './views/create_comment';
export function handleNewPost(post: Post, msg?: {data?: NewPostMessageProps & GroupChannel}): ActionFuncAsync<boolean, GlobalState> { export function handleNewPost(post: Post, msg?: {data?: NewPostMessageProps & GroupChannel}): ActionFuncAsync<boolean, GlobalState> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
@ -106,7 +106,7 @@ export function unflagPost(postId: string): ActionFuncAsync {
}; };
} }
export function createPost(post: Post, files: FileInfo[]): ActionFuncAsync { export function createPost(post: Post, files: FileInfo[], afterSubmit?: (response: SubmitPostReturnType) => void): ActionFuncAsync<PostActions.CreatePostReturnType, GlobalState> {
return async (dispatch) => { return async (dispatch) => {
// parse message and emit emoji event // parse message and emit emoji event
const emojis = matchEmoticons(post.message); const emojis = matchEmoticons(post.message);
@ -115,12 +115,7 @@ export function createPost(post: Post, files: FileInfo[]): ActionFuncAsync {
dispatch(addRecentEmojis(trimmedEmojis)); dispatch(addRecentEmojis(trimmedEmojis));
} }
let result; const result = await dispatch(PostActions.createPost(post, files, afterSubmit));
if (UserAgent.isIosClassic()) {
result = await dispatch(PostActions.createPostImmediately(post, files));
} else {
result = await dispatch(PostActions.createPost(post, files));
}
if (post.root_id) { if (post.root_id) {
dispatch(storeCommentDraft(post.root_id, null)); dispatch(storeCommentDraft(post.root_id, null));
@ -146,23 +141,23 @@ function storeCommentDraft(rootPostId: string, draft: null): ActionFunc {
}; };
} }
export function submitReaction(postId: string, action: string, emojiName: string): ActionFunc<unknown, GlobalState> { export function submitReaction(postId: string, action: string, emojiName: string): ActionFuncAsync<PostActions.SubmitReactionReturnType, GlobalState> {
return (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState() as GlobalState; const state = getState() as GlobalState;
const getIsReactionAlreadyAddedToPost = makeGetIsReactionAlreadyAddedToPost(); const getIsReactionAlreadyAddedToPost = makeGetIsReactionAlreadyAddedToPost();
const isReactionAlreadyAddedToPost = getIsReactionAlreadyAddedToPost(state, postId, emojiName); const isReactionAlreadyAddedToPost = getIsReactionAlreadyAddedToPost(state, postId, emojiName);
if (action === '+' && !isReactionAlreadyAddedToPost) { if (action === '+' && !isReactionAlreadyAddedToPost) {
dispatch(addReaction(postId, emojiName)); return dispatch(addReaction(postId, emojiName));
} else if (action === '-' && isReactionAlreadyAddedToPost) { } else if (action === '-' && isReactionAlreadyAddedToPost) {
dispatch(PostActions.removeReaction(postId, emojiName)); return dispatch(PostActions.removeReaction(postId, emojiName));
} }
return {data: true}; return {error: new Error(`unknown action ${action}`)};
}; };
} }
export function toggleReaction(postId: string, emojiName: string): ActionFuncAsync<unknown, GlobalState> { export function toggleReaction(postId: string, emojiName: string): ActionFuncAsync<PostActions.SubmitReactionReturnType, GlobalState> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
const getIsReactionAlreadyAddedToPost = makeGetIsReactionAlreadyAddedToPost(); const getIsReactionAlreadyAddedToPost = makeGetIsReactionAlreadyAddedToPost();
@ -176,9 +171,9 @@ export function toggleReaction(postId: string, emojiName: string): ActionFuncAsy
}; };
} }
export function addReaction(postId: string, emojiName: string): ActionFunc { export function addReaction(postId: string, emojiName: string): ActionFuncAsync<PostActions.SubmitReactionReturnType, GlobalState> {
const getUniqueEmojiNameReactionsForPost = makeGetUniqueEmojiNameReactionsForPost(); const getUniqueEmojiNameReactionsForPost = makeGetUniqueEmojiNameReactionsForPost();
return (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState() as GlobalState; const state = getState() as GlobalState;
const config = getConfig(state); const config = getConfig(state);
const uniqueEmojiNames = getUniqueEmojiNameReactionsForPost(state, postId) ?? []; const uniqueEmojiNames = getUniqueEmojiNameReactionsForPost(state, postId) ?? [];
@ -193,12 +188,12 @@ export function addReaction(postId: string, emojiName: string): ActionFunc {
onExited: () => closeModal(ModalIdentifiers.REACTION_LIMIT_REACHED), onExited: () => closeModal(ModalIdentifiers.REACTION_LIMIT_REACHED),
}, },
})); }));
return {data: false}; return {error: new Error('reached reaction limit')};
} }
dispatch(PostActions.addReaction(postId, emojiName));
dispatch(addRecentEmoji(emojiName)); dispatch(addRecentEmoji(emojiName));
return {data: true}; const result = await dispatch(PostActions.addReaction(postId, emojiName));
return result;
}; };
} }

View File

@ -3,9 +3,8 @@
import type {Post} from '@mattermost/types/posts'; import type {Post} from '@mattermost/types/posts';
import { import type {CreatePostReturnType, SubmitReactionReturnType} from 'mattermost-redux/actions/posts';
addMessageIntoHistory, import {addMessageIntoHistory} from 'mattermost-redux/actions/posts';
} from 'mattermost-redux/actions/posts';
import {Permissions} from 'mattermost-redux/constants'; import {Permissions} from 'mattermost-redux/constants';
import {createSelector} from 'mattermost-redux/selectors/create_selector'; import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import {getChannel} from 'mattermost-redux/selectors/entities/channels';
@ -25,6 +24,7 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {ActionFunc, ActionFuncAsync} from 'mattermost-redux/types/actions'; import type {ActionFunc, ActionFuncAsync} from 'mattermost-redux/types/actions';
import {isPostPendingOrFailed} from 'mattermost-redux/utils/post_utils'; import {isPostPendingOrFailed} from 'mattermost-redux/utils/post_utils';
import type {ExecuteCommandReturnType} from 'actions/command';
import {executeCommand} from 'actions/command'; import {executeCommand} from 'actions/command';
import {runMessageWillBePostedHooks, runSlashCommandWillBePostedHooks} from 'actions/hooks'; import {runMessageWillBePostedHooks, runSlashCommandWillBePostedHooks} from 'actions/hooks';
import * as PostActions from 'actions/post_actions'; import * as PostActions from 'actions/post_actions';
@ -56,7 +56,7 @@ export function updateCommentDraft(rootId: string, draft?: PostDraft, save = fal
return updateDraft(key, draft ?? null, rootId, save); return updateDraft(key, draft ?? null, rootId, save);
} }
export function submitPost(channelId: string, rootId: string, draft: PostDraft): ActionFuncAsync { export function submitPost(channelId: string, rootId: string, draft: PostDraft, afterSubmit?: (response: SubmitPostReturnType) => void): ActionFuncAsync<CreatePostReturnType, GlobalState> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
@ -103,11 +103,13 @@ export function submitPost(channelId: string, rootId: string, draft: PostDraft):
post = hookResult.data; post = hookResult.data;
return dispatch(PostActions.createPost(post, draft.fileInfos)); return dispatch(PostActions.createPost(post, draft.fileInfos, afterSubmit));
}; };
} }
export function submitCommand(channelId: string, rootId: string, draft: PostDraft): ActionFuncAsync<unknown, GlobalState> { type SubmitCommandRerturnType = ExecuteCommandReturnType & CreatePostReturnType;
export function submitCommand(channelId: string, rootId: string, draft: PostDraft): ActionFuncAsync<SubmitCommandRerturnType, GlobalState> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
@ -126,13 +128,13 @@ export function submitCommand(channelId: string, rootId: string, draft: PostDraf
return {error: hookResult.error}; return {error: hookResult.error};
} else if (!hookResult.data!.message && !hookResult.data!.args) { } else if (!hookResult.data!.message && !hookResult.data!.args) {
// do nothing with an empty return from a hook // do nothing with an empty return from a hook
return {}; return {error: new Error('command not submitted due to plugin hook')};
} }
message = hookResult.data!.message; message = hookResult.data!.message;
args = hookResult.data!.args; args = hookResult.data!.args;
const {error} = await dispatch(executeCommand(message, args)); const {error, data} = await dispatch(executeCommand(message, args));
if (error) { if (error) {
if (error.sendMessage) { if (error.sendMessage) {
@ -141,7 +143,7 @@ export function submitCommand(channelId: string, rootId: string, draft: PostDraf
throw (error); throw (error);
} }
return {}; return {data: data!};
}; };
} }
@ -175,7 +177,9 @@ export function makeOnSubmit(channelId: string, rootId: string, latestPostId: st
}; };
} }
export function onSubmit(draft: PostDraft, options: {ignoreSlash?: boolean}): ActionFuncAsync<boolean, GlobalState> { export type SubmitPostReturnType = CreatePostReturnType & SubmitCommandRerturnType & SubmitReactionReturnType;
export function onSubmit(draft: PostDraft, options: {ignoreSlash?: boolean; afterSubmit?: (response: SubmitPostReturnType) => void}): ActionFuncAsync<SubmitPostReturnType, GlobalState> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const {message, channelId, rootId} = draft; const {message, channelId, rootId} = draft;
const state = getState(); const state = getState();
@ -190,14 +194,16 @@ export function onSubmit(draft: PostDraft, options: {ignoreSlash?: boolean}): Ac
if (isReaction && emojiMap.has(isReaction[2])) { if (isReaction && emojiMap.has(isReaction[2])) {
const latestPostId = getLatestInteractablePostId(state, channelId, rootId); const latestPostId = getLatestInteractablePostId(state, channelId, rootId);
if (latestPostId) { if (latestPostId) {
dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2])); return dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2]));
} }
} else if (message.indexOf('/') === 0 && !options.ignoreSlash) { return {error: new Error('no post to react to')};
await dispatch(submitCommand(channelId, rootId, draft));
} else {
await dispatch(submitPost(channelId, rootId, draft));
} }
return {data: true};
if (message.indexOf('/') === 0 && !options.ignoreSlash) {
return dispatch(submitCommand(channelId, rootId, draft));
}
return dispatch(submitPost(channelId, rootId, draft, options.afterSubmit));
}; };
} }

View File

@ -5,6 +5,8 @@
import React from 'react'; import React from 'react';
import type {SubmitPostReturnType} from 'actions/views/create_comment';
import AdvancedTextEditor from 'components/advanced_text_editor/advanced_text_editor'; import AdvancedTextEditor from 'components/advanced_text_editor/advanced_text_editor';
import {Locations} from 'utils/constants'; import {Locations} from 'utils/constants';
@ -19,6 +21,11 @@ export type Props = {
isThreadView?: boolean; isThreadView?: boolean;
placeholder?: string; placeholder?: string;
/**
* Used by plugins to act after the post is made
*/
afterSubmit?: (response: SubmitPostReturnType) => void;
} }
const AdvancedCreateComment = ({ const AdvancedCreateComment = ({
@ -26,6 +33,7 @@ const AdvancedCreateComment = ({
rootId, rootId,
isThreadView, isThreadView,
placeholder, placeholder,
afterSubmit,
}: Props) => { }: Props) => {
return ( return (
<AdvancedTextEditor <AdvancedTextEditor
@ -34,6 +42,7 @@ const AdvancedCreateComment = ({
postId={rootId} postId={rootId}
isThreadView={isThreadView} isThreadView={isThreadView}
placeholder={placeholder} placeholder={placeholder}
afterSubmit={afterSubmit}
/> />
); );
}; };

View File

@ -18,6 +18,7 @@ import {getCurrentUserId, isCurrentUserGuestUser, getStatusForUserId, makeGetDis
import * as GlobalActions from 'actions/global_actions'; import * as GlobalActions from 'actions/global_actions';
import {actionOnGlobalItemsWithPrefix} from 'actions/storage'; import {actionOnGlobalItemsWithPrefix} from 'actions/storage';
import type {SubmitPostReturnType} from 'actions/views/create_comment';
import {removeDraft, updateDraft} from 'actions/views/drafts'; import {removeDraft, updateDraft} from 'actions/views/drafts';
import {makeGetDraft} from 'selectors/rhs'; import {makeGetDraft} from 'selectors/rhs';
import {connectionErrorCount} from 'selectors/views/system'; import {connectionErrorCount} from 'selectors/views/system';
@ -79,14 +80,20 @@ type Props = {
postId: string; postId: string;
isThreadView?: boolean; isThreadView?: boolean;
placeholder?: string; placeholder?: string;
/**
* Used by plugins to act after the post is made
*/
afterSubmit?: (response: SubmitPostReturnType) => void;
} }
const AdvanceTextEditor = ({ const AdvancedTextEditor = ({
location, location,
channelId, channelId,
postId, postId,
isThreadView = false, isThreadView = false,
placeholder, placeholder,
afterSubmit,
}: Props) => { }: Props) => {
const {formatMessage} = useIntl(); const {formatMessage} = useIntl();
@ -244,7 +251,7 @@ const AdvanceTextEditor = ({
isValidPersistentNotifications, isValidPersistentNotifications,
onSubmitCheck: prioritySubmitCheck, onSubmitCheck: prioritySubmitCheck,
} = usePriority(draft, handleDraftChange, focusTextbox, showPreview); } = usePriority(draft, handleDraftChange, focusTextbox, showPreview);
const [handleSubmit, errorClass] = useSubmit(draft, postError, channelId, postId, serverError, lastBlurAt, focusTextbox, setServerError, setPostError, setShowPreview, handleDraftChange, prioritySubmitCheck); const [handleSubmit, errorClass] = useSubmit(draft, postError, channelId, postId, serverError, lastBlurAt, focusTextbox, setServerError, setPostError, setShowPreview, handleDraftChange, prioritySubmitCheck, afterSubmit);
const [handleKeyDown, postMsgKeyPress] = useKeyHandler( const [handleKeyDown, postMsgKeyPress] = useKeyHandler(
draft, draft,
channelId, channelId,
@ -660,4 +667,4 @@ const AdvanceTextEditor = ({
); );
}; };
export default AdvanceTextEditor; export default AdvancedTextEditor;

View File

@ -16,6 +16,7 @@ import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'
import {getCurrentUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; import {getCurrentUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
import {scrollPostListToBottom} from 'actions/views/channel'; import {scrollPostListToBottom} from 'actions/views/channel';
import type {SubmitPostReturnType} from 'actions/views/create_comment';
import {onSubmit} from 'actions/views/create_comment'; import {onSubmit} from 'actions/views/create_comment';
import {openModal} from 'actions/views/modals'; import {openModal} from 'actions/views/modals';
@ -60,6 +61,7 @@ const useSubmit = (
setShowPreview: (showPreview: boolean) => void, setShowPreview: (showPreview: boolean) => void,
handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void, handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void,
prioritySubmitCheck: (onConfirm: () => void) => boolean, prioritySubmitCheck: (onConfirm: () => void) => boolean,
afterSubmit?: (response: SubmitPostReturnType) => void,
): [ ): [
(e: React.FormEvent, submittingDraft?: PostDraft) => void, (e: React.FormEvent, submittingDraft?: PostDraft) => void,
string | null, string | null,
@ -153,7 +155,7 @@ const useSubmit = (
setServerError(null); setServerError(null);
const ignoreSlash = isErrorInvalidSlashCommand(serverError) && serverError?.submittedMessage === submittingDraft.message; const ignoreSlash = isErrorInvalidSlashCommand(serverError) && serverError?.submittedMessage === submittingDraft.message;
const options = {ignoreSlash}; const options = {ignoreSlash, afterSubmit};
try { try {
await dispatch(onSubmit(submittingDraft, options)); await dispatch(onSubmit(submittingDraft, options));
@ -188,7 +190,7 @@ const useSubmit = (
} }
isDraftSubmitting.current = false; isDraftSubmitting.current = false;
}, [handleDraftChange, dispatch, draft, focusTextbox, isRootDeleted, postError, serverError, showPostDeletedModal, channelId, postId, lastBlurAt, setPostError, setServerError]); }, [handleDraftChange, dispatch, draft, focusTextbox, isRootDeleted, postError, serverError, showPostDeletedModal, channelId, postId, lastBlurAt, setPostError, setServerError, afterSubmit]);
const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number) => { const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number) => {
dispatch(openModal({ dispatch(openModal({

View File

@ -89,38 +89,6 @@ describe('Actions.Posts', () => {
expect(!postsInChannel[channelId]).toBeTruthy(); expect(!postsInChannel[channelId]).toBeTruthy();
}); });
it('maintain postReplies', async () => {
const channelId = TestHelper.basicChannel!.id;
const post = TestHelper.fakePost(channelId);
const postId = TestHelper.generateId();
nock(Client4.getBaseRoute()).
post('/posts').
reply(201, {...post, id: postId});
await store.dispatch(Actions.createPostImmediately(post));
const post2 = TestHelper.fakePostWithId(channelId);
post2.root_id = postId;
nock(Client4.getBaseRoute()).
post('/posts').
reply(201, post2);
await store.dispatch(Actions.createPostImmediately(post2));
expect(store.getState().entities.posts.postsReplies[postId]).toBe(1);
nock(Client4.getBaseRoute()).
delete(`/posts/${post2.id}`).
reply(200, OK_RESPONSE);
await store.dispatch(Actions.deletePost(post2));
await store.dispatch(Actions.removePost(post2));
expect(store.getState().entities.posts.postsReplies[postId]).toBe(0);
});
it('resetCreatePostRequest', async () => { it('resetCreatePostRequest', async () => {
const channelId = TestHelper.basicChannel!.id; const channelId = TestHelper.basicChannel!.id;
const post = TestHelper.fakePost(channelId); const post = TestHelper.fakePost(channelId);

View File

@ -8,6 +8,7 @@ import type {Channel, ChannelUnread} from '@mattermost/types/channels';
import type {FetchPaginatedThreadOptions} from '@mattermost/types/client4'; import type {FetchPaginatedThreadOptions} from '@mattermost/types/client4';
import type {Group} from '@mattermost/types/groups'; import type {Group} from '@mattermost/types/groups';
import type {Post, PostList, PostAcknowledgement} from '@mattermost/types/posts'; import type {Post, PostList, PostAcknowledgement} from '@mattermost/types/posts';
import type {Reaction} from '@mattermost/types/reactions';
import type {GlobalState} from '@mattermost/types/store'; import type {GlobalState} from '@mattermost/types/store';
import type {UserProfile} from '@mattermost/types/users'; import type {UserProfile} from '@mattermost/types/users';
@ -168,7 +169,12 @@ export function getPost(postId: string): ActionFuncAsync<Post> {
}; };
} }
export function createPost(post: Post, files: any[] = []): ActionFuncAsync { export type CreatePostReturnType = {
created?: boolean;
pending?: string;
}
export function createPost(post: Post, files: any[] = [], afterSubmit?: (response: any) => void): ActionFuncAsync<CreatePostReturnType, GlobalState> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
const currentUserId = state.entities.users.currentUserId; const currentUserId = state.entities.users.currentUserId;
@ -178,7 +184,7 @@ export function createPost(post: Post, files: any[] = []): ActionFuncAsync {
let actions: AnyAction[] = []; let actions: AnyAction[] = [];
if (PostSelectors.isPostIdSending(state, pendingPostId)) { if (PostSelectors.isPostIdSending(state, pendingPostId)) {
return {data: true}; return {data: {pending: pendingPostId}};
} }
let newPost = { let newPost = {
@ -266,6 +272,8 @@ export function createPost(post: Post, files: any[] = []): ActionFuncAsync {
} }
dispatch(batchActions(actions, 'BATCH_CREATE_POST')); dispatch(batchActions(actions, 'BATCH_CREATE_POST'));
afterSubmit?.({created});
return {data: {created}};
} catch (error) { } catch (error) {
const data = { const data = {
...newPost, ...newPost,
@ -288,107 +296,11 @@ export function createPost(post: Post, files: any[] = []): ActionFuncAsync {
} }
dispatch(batchActions(actions, 'BATCH_CREATE_POST_FAILED')); dispatch(batchActions(actions, 'BATCH_CREATE_POST_FAILED'));
return {error};
} }
}()); }());
return {data: true}; return {data: {created: true}};
};
}
export function createPostImmediately(post: Post, files: any[] = []): ActionFuncAsync<Post> {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = state.entities.users.currentUserId;
const timestamp = Date.now();
const pendingPostId = `${currentUserId}:${timestamp}`;
let newPost: Post = {
...post,
pending_post_id: pendingPostId,
create_at: timestamp,
update_at: timestamp,
reply_count: 0,
};
if (post.root_id) {
newPost.reply_count = PostSelectors.getPostRepliesCount(state, post.root_id) + 1;
}
if (files.length) {
const fileIds = files.map((file) => file.id);
newPost = {
...newPost,
file_ids: fileIds,
};
dispatch({
type: FileTypes.RECEIVED_FILES_FOR_POST,
postId: pendingPostId,
data: files,
});
dispatch({
type: ChannelTypes.INCREMENT_FILE_COUNT,
amount: files.length,
id: newPost.channel_id,
});
}
const crtEnabled = isCollapsedThreadsEnabled(state);
dispatch(receivedNewPost({
...newPost,
id: pendingPostId,
}, crtEnabled));
try {
const created = await Client4.createPost({...newPost, create_at: 0});
newPost.id = created.id;
newPost.reply_count = created.reply_count;
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch({type: PostTypes.CREATE_POST_FAILURE, data: newPost, error});
dispatch(removePost({
...newPost,
id: pendingPostId,
}));
dispatch(logError(error));
return {error};
}
const actions: AnyAction[] = [
receivedPost(newPost, crtEnabled),
{
type: PostTypes.CREATE_POST_SUCCESS,
},
{
type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT,
data: {
channelId: newPost.channel_id,
amount: 1,
amountRoot: newPost.root_id === '' ? 1 : 0,
},
},
{
type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT,
data: {
channelId: newPost.channel_id,
amount: 1,
amountRoot: newPost.root_id === '' ? 1 : 0,
},
},
];
if (files) {
actions.push({
type: FileTypes.RECEIVED_FILES_FOR_POST,
postId: newPost.id,
data: files,
});
}
dispatch(batchActions(actions));
return {data: newPost};
}; };
} }
@ -589,7 +501,12 @@ export function unpinPost(postId: string): ActionFuncAsync {
}; };
} }
export function addReaction(postId: string, emojiName: string): ActionFuncAsync { export type SubmitReactionReturnType = {
reaction?: Reaction;
removedReaction?: boolean;
}
export function addReaction(postId: string, emojiName: string): ActionFuncAsync<SubmitReactionReturnType, GlobalState> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const currentUserId = getState().entities.users.currentUserId; const currentUserId = getState().entities.users.currentUserId;
@ -607,11 +524,11 @@ export function addReaction(postId: string, emojiName: string): ActionFuncAsync
data: reaction, data: reaction,
}); });
return {data: true}; return {data: {reaction}};
}; };
} }
export function removeReaction(postId: string, emojiName: string): ActionFuncAsync { export function removeReaction(postId: string, emojiName: string): ActionFuncAsync<SubmitReactionReturnType, GlobalState> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const currentUserId = getState().entities.users.currentUserId; const currentUserId = getState().entities.users.currentUserId;
@ -628,7 +545,7 @@ export function removeReaction(postId: string, emojiName: string): ActionFuncAsy
data: {user_id: currentUserId, post_id: postId, emoji_name: emojiName}, data: {user_id: currentUserId, post_id: postId, emoji_name: emojiName},
}); });
return {data: true}; return {data: {removedReaction: true}};
}; };
} }

View File

@ -6,6 +6,7 @@ import {openModal} from 'actions/views/modals';
import {closeRightHandSide, selectPostById} from 'actions/views/rhs'; import {closeRightHandSide, selectPostById} from 'actions/views/rhs';
import {getSelectedPostId, getIsRhsOpen} from 'selectors/rhs'; import {getSelectedPostId, getIsRhsOpen} from 'selectors/rhs';
import AdvancedTextEditor from 'components/advanced_text_editor/advanced_text_editor';
import ChannelInviteModal from 'components/channel_invite_modal'; import ChannelInviteModal from 'components/channel_invite_modal';
import ChannelMembersModal from 'components/channel_members_modal'; import ChannelMembersModal from 'components/channel_members_modal';
import {openPricingModal} from 'components/global_header/right_controls/plan_upgrade_button'; import {openPricingModal} from 'components/global_header/right_controls/plan_upgrade_button';
@ -26,7 +27,6 @@ import {formatText} from 'utils/text_formatting';
import {useWebSocket, useWebSocketClient, WebSocketContext} from 'utils/use_websocket'; import {useWebSocket, useWebSocketClient, WebSocketContext} from 'utils/use_websocket';
import {imageURLForUser} from 'utils/utils'; import {imageURLForUser} from 'utils/utils';
import CreatePost from './exported_create_post';
import {openInteractiveDialog} from './interactive_dialog'; // This import has intentional side effects. Do not remove without research. import {openInteractiveDialog} from './interactive_dialog'; // This import has intentional side effects. Do not remove without research.
import Textbox from './textbox'; import Textbox from './textbox';
@ -89,8 +89,8 @@ window.Components = {
BotBadge: BotTag, BotBadge: BotTag,
StartTrialFormModal, StartTrialFormModal,
ThreadViewer, ThreadViewer,
CreatePost,
PostMessagePreview, PostMessagePreview,
AdvancedTextEditor,
}; };
// This is a prototype of the Product API for use by internal plugins only while we transition to the proper architecture // This is a prototype of the Product API for use by internal plugins only while we transition to the proper architecture