Skip to content
This repository has been archived by the owner on Mar 13, 2024. It is now read-only.

Automated cherry pick of #7773 #7857

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions actions/apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {Client4} from 'mattermost-redux/client';
import {Action, ActionFunc, DispatchFunc} from 'mattermost-redux/types/actions';
import {AppCallResponse, AppForm, AppCallType, AppCallRequest} from 'mattermost-redux/types/apps';
import {AppCallTypes, AppCallResponseTypes} from 'mattermost-redux/constants/apps';

import {openModal} from 'actions/views/modals';

import AppsForm from 'components/apps_form';

import {ModalIdentifiers} from 'utils/constants';
import {getSiteURL, shouldOpenInNewTab} from 'utils/url';
import {browserHistory} from 'utils/browser_history';
import {makeCallErrorResponse} from 'utils/apps';

export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType, intl: any): ActionFunc {
return async (dispatch: DispatchFunc) => {
try {
const res = await Client4.executeAppCall(call, type) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;

switch (responseType) {
case AppCallResponseTypes.OK:
return {data: res};
case AppCallResponseTypes.ERROR:
return {data: res};
case AppCallResponseTypes.FORM:
if (!res.form) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.form.no_form',
defaultMessage: 'Response type is `form`, but no form was included in response.',
});
return {data: makeCallErrorResponse(errMsg)};
}

if (type === AppCallTypes.SUBMIT) {
dispatch(openAppsModal(res.form, call));
}

return {data: res};
case AppCallResponseTypes.NAVIGATE:
if (!res.navigate_to_url) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.navigate.no_url',
defaultMessage: 'Response type is `navigate`, but no url was included in response.',
});
return {data: makeCallErrorResponse(errMsg)};
}

if (type !== AppCallTypes.SUBMIT) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.navigate.no_submit',
defaultMessage: 'Response type is `navigate`, but the call was not a submission.',
});
return {data: makeCallErrorResponse(errMsg)};
}

if (shouldOpenInNewTab(res.navigate_to_url, getSiteURL())) {
window.open(res.navigate_to_url);
return {data: res};
}

browserHistory.push(res.navigate_to_url.slice(getSiteURL().length));
return {data: res};
default: {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {type: responseType});
return {data: makeCallErrorResponse(errMsg)};
}
}
} catch (error) {
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
return {data: makeCallErrorResponse(errMsg)};
}
};
}

export function openAppsModal(form: AppForm, call: AppCallRequest): Action {
return openModal({
modalId: ModalIdentifiers.APPS_MODAL,
dialogType: AppsForm,
dialogProps: {
form,
call,
},
});
}
105 changes: 105 additions & 0 deletions actions/command.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {Client4} from 'mattermost-redux/client';
import * as Channels from 'mattermost-redux/selectors/entities/channels';
import * as Teams from 'mattermost-redux/selectors/entities/teams';

import {AppCallResponseTypes} from 'mattermost-redux/constants/apps';

import {ActionTypes, Constants} from 'utils/constants';
import * as UserAgent from 'utils/user_agent';
import * as GlobalActions from 'actions/global_actions';
Expand All @@ -24,14 +26,29 @@ const currentTeamId = '321';
const currentUserId = 'user123';
const initialState = {
entities: {
admin: {
pluginStatuses: {
'com.mattermost.apps': {
state: 2,
},
},
},
general: {
config: {
ExperimentalViewArchivedChannels: 'false',
EnableLegacySidebar: 'true',
},
},
posts: {
posts: {
root_id: {id: 'root_id', channel_id: '123'},
},
},
channels: {
currentChannelId,
channels: {
123: {id: '123', team_id: '456'},
},
},
preferences: {
myPreferences: {},
Expand All @@ -51,6 +68,46 @@ const initialState = {
},
},
},
apps: {
bindings: [{
location: '/command',
bindings: [{
app_id: 'appid',
label: 'appid',
bindings: [
{
app_id: 'appid',
label: 'custom',
description: 'Run the command.',
call: {
path: 'https://someserver.com/command',
},
form: {
fields: [
{
name: 'key1',
label: 'key1',
type: 'text',
position: 1,
},
{
name: 'key2',
label: 'key2',
type: 'static_select',
options: [
{
label: 'Value 2',
value: 'value2',
},
],
},
],
},
},
],
}],
}],
},
},
views: {
rhs: {
Expand Down Expand Up @@ -177,4 +234,52 @@ describe('executeCommand', () => {
expect(result).toEqual({data: true});
});
});

describe('app command', () => {
test('should call executeAppCall', async () => {
const state = {
...initialState,
entities: {
...initialState.entities,
general: {
...initialState.entities.general,
config: {
...initialState.entities.general.config,
FeatureFlagAppsEnabled: 'true',
},
},
},
};
store = await mockStore(state);
const f = Client4.executeAppCall;
const mocked = jest.fn().mockResolvedValue(Promise.resolve({
type: AppCallResponseTypes.OK,
markdown: 'Success',
}));
Client4.executeAppCall = mocked;

const result = await store.dispatch(executeCommand('/appid custom value1 --key2 value2', {channel_id: '123', root_id: 'root_id'}));
Client4.executeAppCall = f;

expect(mocked).toHaveBeenCalledWith({
context: {
app_id: 'appid',
channel_id: '123',
location: '/command',
root_id: 'root_id',
team_id: '456',
},
raw_command: '/appid custom value1 --key2 value2',
path: 'https://someserver.com/command',
values: {
key1: 'value1',
key2: {label: 'Value 2', value: 'value2'},
},
expand: {},
query: undefined,
selected_field: undefined,
}, 'submit');
expect(result).toEqual({data: true});
});
});
});
65 changes: 60 additions & 5 deletions actions/command.js → actions/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,38 @@ import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getCurrentChannel, getRedirectChannelNameForTeam, isFavoriteChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getCurrentRelativeTeamUrl, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {appsEnabled} from 'mattermost-redux/selectors/entities/apps';
import {IntegrationTypes} from 'mattermost-redux/action_types';
import {ActionFunc, DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions';
import type {CommandArgs} from 'mattermost-redux/types/integrations';

import {AppCallResponseTypes, AppCallTypes} from 'mattermost-redux/constants/apps';

import {AppCallResponse} from 'mattermost-redux/types/apps';

import {openModal} from 'actions/views/modals';
import * as GlobalActions from 'actions/global_actions';
import * as PostActions from 'actions/post_actions.jsx';

import {isUrlSafe, getSiteURL} from 'utils/url';
import {localizeMessage, getUserIdFromChannelName} from 'utils/utils.jsx';
import {localizeMessage, getUserIdFromChannelName, localizeAndFormatMessage} from 'utils/utils.jsx';
import * as UserAgent from 'utils/user_agent';
import {Constants, ModalIdentifiers} from 'utils/constants';
import {browserHistory} from 'utils/browser_history';

import UserSettingsModal from 'components/user_settings/modal';
import {AppCommandParser} from 'components/suggestion/command_provider/app_command_parser/app_command_parser';
import {intlShim} from 'components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies';

import {GlobalState} from 'types/store';

import {t} from 'utils/i18n';

import {doAppCall} from './apps';

export function executeCommand(message, args) {
return async (dispatch, getState) => {
const state = getState();
export function executeCommand(message: string, args: CommandArgs): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState() as GlobalState;

let msg = message;

Expand All @@ -47,7 +62,7 @@ export function executeCommand(message, args) {
GlobalActions.toggleShortcutsModal();
return {data: true};
case '/leave': {
// /leave command not supported in reply threads.
// /leave command not supported in reply threads.
if (args.channel_id && (args.root_id || args.parent_id)) {
GlobalActions.sendEphemeralPost('/leave is not supported in reply threads. Use it in the center channel instead.', args.channel_id, args.parent_id);
return {data: true};
Expand Down Expand Up @@ -93,6 +108,46 @@ export function executeCommand(message, args) {
dispatch(PostActions.resetEmbedVisibility());
}

if (appsEnabled(state)) {
const getGlobalState = () => getState() as GlobalState;
const createErrorMessage = (errMessage: string) => {
return {error: {message: errMessage}};
};
const parser = new AppCommandParser({dispatch, getState: getGlobalState} as any, intlShim, args.channel_id, args.root_id);
if (parser.isAppCommand(msg)) {
try {
const call = await parser.composeCallFromCommand(msg);
if (!call) {
return createErrorMessage(localizeMessage('apps.error.commands.compose_call', 'Error composing command submission'));
}

const res = await dispatch(doAppCall(call, AppCallTypes.SUBMIT, intlShim)) as {data: AppCallResponse};

const callResp = res.data;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
GlobalActions.sendEphemeralPost(callResp.markdown, args.channel_id, args.parent_id);
}
return {data: true};
case AppCallResponseTypes.ERROR:
return createErrorMessage(callResp.error || localizeMessage('apps.error.unknown', 'Unknown error.'));
case AppCallResponseTypes.FORM:
case AppCallResponseTypes.NAVIGATE:
return {data: true};
default:
return createErrorMessage(localizeAndFormatMessage(
t('apps.error.responses.unknown_type'),
'App response type not supported. Response type: {type}.',
{type: callResp.type},
));
}
} catch (err) {
return createErrorMessage(err.message || localizeMessage('apps.error.unknown', 'Unknown error.'));
}
}
}

let data;
try {
data = await Client4.executeCommand(msg, args);
Expand Down
8 changes: 7 additions & 1 deletion actions/global_actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentTeamId, getMyTeams, getTeam, getMyTeamMember, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUser, getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getCurrentChannelStats, getCurrentChannelId, getMyChannelMember, getRedirectChannelNameForTeam, getChannelsNameMapInTeam, getAllDirectChannels} from 'mattermost-redux/selectors/entities/channels';
import {appsEnabled} from 'mattermost-redux/selectors/entities/apps';
import {ChannelTypes} from 'mattermost-redux/action_types';
import {fetchAppBindings} from 'mattermost-redux/actions/apps';
import {Channel, ChannelMembership} from 'mattermost-redux/types/channels';
import {UserProfile} from 'mattermost-redux/types/users';
import {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions';
Expand Down Expand Up @@ -94,6 +96,10 @@ export function emitChannelClickEvent(channel: Channel) {
channel: chan,
member: member || {},
}]));

if (appsEnabled(state)) {
dispatch(fetchAppBindings(userId, chan.id));
}
}

if (channel.fake) {
Expand Down Expand Up @@ -180,7 +186,7 @@ export function showMobileSubMenuModal(elements: any[]) { // TODO Use more speci
dispatch(openModal(submenuModalData));
}

export function sendEphemeralPost(message: string, channelId: string, parentId: string) {
export function sendEphemeralPost(message: string, channelId?: string, parentId?: string): void {
const timestamp = Utils.getTimestamp();
const post = {
id: Utils.generateId(),
Expand Down
Loading