diff --git a/webapp/channels/src/actions/command.ts b/webapp/channels/src/actions/command.ts index d22311f36b..8505a44c74 100644 --- a/webapp/channels/src/actions/command.ts +++ b/webapp/channels/src/actions/command.ts @@ -220,6 +220,10 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs } if (data.trigger_id) { + const dialogArguments = { + channel_id: args.channel_id, + }; + dispatch({type: IntegrationTypes.RECEIVED_DIALOG_ARGUMENTS, data: dialogArguments}); dispatch({type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, data: data.trigger_id}); } diff --git a/webapp/channels/src/actions/integration_actions.test.ts b/webapp/channels/src/actions/integration_actions.test.ts index c62c788c0c..7566f089e0 100644 --- a/webapp/channels/src/actions/integration_actions.test.ts +++ b/webapp/channels/src/actions/integration_actions.test.ts @@ -3,6 +3,7 @@ import type {IncomingWebhook, OutgoingWebhook, Command, OAuthApp} from '@mattermost/types/integrations'; +import * as IntegrationActions from 'mattermost-redux/actions/integrations'; import {getProfilesByIds} from 'mattermost-redux/actions/users'; import * as Actions from 'actions/integration_actions'; @@ -15,6 +16,12 @@ jest.mock('mattermost-redux/actions/users', () => ({ }), })); +jest.mock('mattermost-redux/actions/integrations', () => ({ + submitInteractiveDialog: jest.fn(() => { + return {type: 'MOCK_SUBMIT_DIALOG', data: {errors: {}}}; + }), +})); + interface CustomMatchers { arrayContainingExactly(stringArray: string[]): R; } @@ -37,6 +44,10 @@ describe('actions/integration_actions', () => { currentUserId: 'current_user_id', profiles: {current_user_id: {id: 'current_user_id', username: 'current_user'}, user_id3: {id: 'user_id3', username: 'user3'}, user_id4: {id: 'user_id4', username: 'user4'}}, }, + channels: { + currentChannelId: 'current_channel_id', + }, + integrations: {}, }, }; @@ -119,4 +130,62 @@ describe('actions/integration_actions', () => { expect(getProfilesByIds).not.toHaveBeenCalled(); }); }); + + describe('submitInteractiveDialog', () => { + test('submitInteractiveDialog with current channel', async () => { + const testState = { + ...initialState, + entities: { + ...initialState.entities, + integrations: { + ...initialState.entities.integrations, + dialogArguments: { + channel_id: 'dialog_channel_id', + }, + }, + }, + }; + const testStore = mockStore(testState); + const submission = { + callback_id: 'callback_id', + state: 'state', + submission: { + name: 'value', + }, + user_id: 'current_user_id', + team_id: 'team_id1', + channel_id: '', + cancelled: false, + }; + + const expectedSubmission = { + ...submission, + channel_id: 'dialog_channel_id', + }; + + await testStore.dispatch(Actions.submitInteractiveDialog(submission)); + + expect(IntegrationActions.submitInteractiveDialog).toHaveBeenCalledWith(expectedSubmission); + }); + + test('submitInteractiveDialog with currentChannel context', async () => { + const testStore = mockStore(initialState); + + const submission = { + callback_id: 'callback_id', + state: 'state', + submission: { + name: 'value', + }, + user_id: 'current_user_id', + team_id: 'team_id1', + channel_id: 'current_channel_id', + cancelled: false, + }; + + await testStore.dispatch(Actions.submitInteractiveDialog(submission)); + + expect(IntegrationActions.submitInteractiveDialog).toHaveBeenCalledWith(submission); + }); + }); }); diff --git a/webapp/channels/src/actions/integration_actions.tsx b/webapp/channels/src/actions/integration_actions.tsx index a4502dbdfa..89a7589aca 100644 --- a/webapp/channels/src/actions/integration_actions.tsx +++ b/webapp/channels/src/actions/integration_actions.tsx @@ -1,11 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {IncomingWebhook, IncomingWebhooksWithCount, OutgoingWebhook, Command, OAuthApp, OutgoingOAuthConnection} from '@mattermost/types/integrations'; +import type {IncomingWebhook, IncomingWebhooksWithCount, OutgoingWebhook, Command, OAuthApp, OutgoingOAuthConnection, DialogSubmission, SubmitDialogResponse} from '@mattermost/types/integrations'; import * as IntegrationActions from 'mattermost-redux/actions/integrations'; import {getProfilesByIds} from 'mattermost-redux/actions/users'; import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; +import {getDialogArguments} from 'mattermost-redux/selectors/entities/integrations'; import {getUser} from 'mattermost-redux/selectors/entities/users'; import type {ActionFuncAsync} from 'types/store'; @@ -171,3 +172,29 @@ export function loadProfilesForOutgoingOAuthConnections(connections: OutgoingOAu return {data: null}; }; } + +/** + * Proxy action for submitting an interactive dialog + * This enhances the base Redux action by checking for dialog arguments in the state + * before falling back to the current channel ID + */ +export function submitInteractiveDialog(submission: DialogSubmission): ActionFuncAsync { + return async (dispatch, getState) => { + const state = getState(); + + // Get dialog arguments from state if available + const dialogArguments = getDialogArguments(state); + + // Use channel_id from dialog arguments if available + if (dialogArguments && dialogArguments.channel_id) { + submission.channel_id = dialogArguments.channel_id; + } + + // Dispatch the base action with our enhanced submission + const {data, error} = await dispatch(IntegrationActions.submitInteractiveDialog(submission)); + if (error) { + return {error}; + } + return {data}; + }; +} diff --git a/webapp/channels/src/components/interactive_dialog/index.ts b/webapp/channels/src/components/interactive_dialog/index.ts index c28e6a8858..18567cf449 100644 --- a/webapp/channels/src/components/interactive_dialog/index.ts +++ b/webapp/channels/src/components/interactive_dialog/index.ts @@ -6,8 +6,7 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import type {Dispatch} from 'redux'; -import {submitInteractiveDialog} from 'mattermost-redux/actions/integrations'; - +import {submitInteractiveDialog} from 'actions/integration_actions'; import {getEmojiMap} from 'selectors/emojis'; import type {GlobalState} from 'types/store'; diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/integrations.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/integrations.ts index 1dfeab574b..3b6c912ce8 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/integrations.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/integrations.ts @@ -27,5 +27,6 @@ export default keyMirror({ DELETED_OUTGOING_OAUTH_CONNECTION: null, RECEIVED_DIALOG_TRIGGER_ID: null, + RECEIVED_DIALOG_ARGUMENTS: null, RECEIVED_DIALOG: null, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.test.ts index 60cab31a00..24887d7b1d 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.test.ts @@ -731,7 +731,7 @@ describe('Actions.Integrations', () => { expect(oauthApps[created.id].client_secret !== created.client_secret).toBeTruthy(); }); - it('submitInteractiveDialog', async () => { + it('submitInteractiveDialogError', async () => { nock(Client4.getBaseRoute()). post('/actions/dialogs/submit'). reply(200, {errors: {name: 'some error'}}); @@ -752,4 +752,68 @@ describe('Actions.Integrations', () => { expect(data.errors).toBeTruthy(); expect(data.errors.name).toEqual('some error'); }); + + it('submitInteractiveDialog uses submission data', async () => { + const submit: DialogSubmission = { + callback_id: 'callback_id', + channel_id: 'submission_channel_id', + state: 'state', + submission: { + field1: 'value1', + }, + cancelled: false, + team_id: 'submission_team_id', + user_id: TestHelper.generateId(), + }; + + nock(Client4.getBaseRoute()). + post('/actions/dialogs/submit', submit). + reply(200, OK_RESPONSE); + + const {data} = await store.dispatch(Actions.submitInteractiveDialog(submit)); + expect(data).toEqual(OK_RESPONSE); + }); + + it('submitInteractiveDialog uses state information', async () => { + store = configureStore({ + entities: { + users: { + currentUserId: 'currentUserID', + }, + teams: { + currentTeamId: 'currentTeamID', + }, + channels: { + currentChannelId: 'dialog_channel_id', + }, + }, + }); + + const submit: DialogSubmission = { + callback_id: 'callback_id', + channel_id: '', + state: 'state', + submission: { + field1: 'value1', + field2: 'value2', + }, + cancelled: false, + team_id: '', + user_id: TestHelper.generateId(), + }; + + const expectedRequest = { + ...submit, + channel_id: 'dialog_channel_id', + team_id: 'currentTeamID', + user_id: 'currentUserID', + }; + + nock(Client4.getBaseRoute()). + post('/actions/dialogs/submit', expectedRequest). + reply(200, OK_RESPONSE); + + const {data} = await store.dispatch(Actions.submitInteractiveDialog(submit)); + expect(data).toEqual(OK_RESPONSE); + }); }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.ts index ab99625709..0f48b41907 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.ts @@ -497,8 +497,11 @@ export function deleteOutgoingOAuthConnection(id: string): ActionFuncAsync { return async (dispatch, getState) => { const state = getState(); - submission.channel_id = getCurrentChannelId(state); + + // Use the current channel as fallback + submission.channel_id ||= getCurrentChannelId(state); submission.team_id = getCurrentTeamId(state); + submission.user_id = getCurrentUserId(state); let data; try { diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.test.ts index 29b885e02e..aa66c004c8 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.test.ts @@ -1349,6 +1349,43 @@ describe('Actions.Posts', () => { expect(data).toEqual({}); }); + it('doPostActionWithCookie with trigger_id', async () => { + const postId = 'posth67ja7ntdkek6g13dp3wka'; + const actionId = 'action7ja7ntdkek6g13dp3wka'; + const triggerId = 'trigger7ja7ntdkek6g13dp3wka'; + const channelId = 'channel7ja7ntdkek6g13dp3wka'; + + // Setup post in state + store = configureStore({ + entities: { + posts: { + posts: { + [postId]: {id: postId, channel_id: channelId}, + }, + }, + integrations: { + dialogArguments: {}, + }, + }, + }); + + nock(Client4.getBaseRoute()). + post(`/posts/${postId}/actions/${actionId}`). + reply(200, {trigger_id: triggerId}); + + const {data} = await store.dispatch(Actions.doPostActionWithCookie(postId, actionId, '', 'option')); + + // Verify the trigger_id was received and stored in state + const state = store.getState(); + expect(data).toBeTruthy(); + expect(data).toEqual({trigger_id: triggerId}); + expect(data).toBeTruthy(); + + expect(state.entities.integrations.dialogArguments).toBeTruthy(); + expect(state.entities.integrations.dialogTriggerId).toEqual(triggerId); + expect(state.entities.integrations.dialogArguments.channel_id).toEqual(channelId); + }); + it('addMessageIntoHistory', async () => { const {dispatch, getState} = store; diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts index b7ff7af196..c5df41d790 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts @@ -1243,6 +1243,13 @@ export function doPostActionWithCookie(postId: string, actionId: string, actionC type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, data: data.trigger_id, }); + const state = getState(); + const post = PostSelectors.getPost(state, postId); + dispatch({ + type: IntegrationTypes.RECEIVED_DIALOG_ARGUMENTS, + data: { + channel_id: post.channel_id, + }}); } return {data}; diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/integrations.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/integrations.ts index dcb0abfde7..fc62613f02 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/integrations.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/integrations.ts @@ -3,7 +3,7 @@ import {combineReducers} from 'redux'; -import type {Command, IncomingWebhook, OutgoingWebhook, OAuthApp, OutgoingOAuthConnection} from '@mattermost/types/integrations'; +import type {Command, IncomingWebhook, OutgoingWebhook, OAuthApp, OutgoingOAuthConnection, DialogArgs} from '@mattermost/types/integrations'; import type {IDMappedObjects} from '@mattermost/types/utilities'; import type {MMReduxAction} from 'mattermost-redux/action_types'; @@ -296,6 +296,15 @@ function appsBotIDs(state: string[] = [], action: MMReduxAction) { } } +function dialogArguments(state: DialogArgs | null = null, action: MMReduxAction) { + switch (action.type) { + case IntegrationTypes.RECEIVED_DIALOG_ARGUMENTS: + return action.data; + default: + return state; + } +} + function dialogTriggerId(state = '', action: MMReduxAction) { switch (action.type) { case IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID: @@ -343,6 +352,9 @@ export default combineReducers({ // object to represent built-in slash commands systemCommands, + // object containing arguments for interactive dialog + dialogArguments, + // trigger ID for interactive dialogs dialogTriggerId, diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/integrations.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/integrations.ts index eb1f6eb7ba..638d40ae80 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/integrations.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/integrations.ts @@ -34,6 +34,10 @@ export function getOutgoingOAuthConnections(state: GlobalState) { return state.entities.integrations.outgoingOAuthConnections; } +export function getDialogArguments(state: GlobalState) { + return state.entities.integrations.dialogArguments; +} + export const getFilteredIncomingHooks: (state: GlobalState) => IncomingWebhook[] = createSelector( 'getFilteredIncomingHooks', getCurrentTeamId, diff --git a/webapp/platform/types/src/integrations.ts b/webapp/platform/types/src/integrations.ts index 44141b8263..0f6bd919e7 100644 --- a/webapp/platform/types/src/integrations.ts +++ b/webapp/platform/types/src/integrations.ts @@ -69,6 +69,10 @@ export type CommandArgs = { root_id?: string; } +export type DialogArgs = { + channel_id: string; +} + export type CommandResponse = { response_type: string; text: string; @@ -134,6 +138,7 @@ export type IntegrationsState = { appsBotIDs: string[]; systemCommands: IDMappedObjects; commands: IDMappedObjects; + dialogArguments?: DialogArgs; dialogTriggerId: string; dialog?: { url: string;