[Apps Framework] Separate calls (#5877)

* Port https://github.com/mattermost/mattermost-webapp/pull/9263 to mobile

* Fix forms props from commands and missing error->text change

* lint

* various fixes

* fix lookups for command parser

* update app command parser

* fixes

* fixes with embedded forms

* lint and types

* remove bindings.expanded fix for cleaning bindings on render

* lint

* re-expand bindings on post update

Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com>
This commit is contained in:
Daniel Espino García 2022-03-24 16:03:06 +01:00 committed by GitHub
parent 2ec503f5ef
commit df7945318e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1535 additions and 901 deletions

View file

@ -3,17 +3,75 @@
import {sendEphemeralPost} from '@actions/views/post';
import {Client4} from '@client/rest';
import {AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {AppCallResponseTypes} from '@mm-redux/constants/apps';
import {ActionFunc, DispatchFunc} from '@mm-redux/types/actions';
import {AppCallResponse, AppCallRequest, AppCallType, AppContext} from '@mm-redux/types/apps';
import {AppCallResponse, AppCallRequest, AppContext, AppBinding} from '@mm-redux/types/apps';
import {CommandArgs} from '@mm-redux/types/integrations';
import {Post} from '@mm-redux/types/posts';
import {cleanForm, makeCallErrorResponse} from '@utils/apps';
import {cleanForm, createCallRequest, makeCallErrorResponse} from '@utils/apps';
export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType, intl: any): ActionFunc {
export function handleBindingClick<Res=unknown>(binding: AppBinding, context: AppContext, intl: any): ActionFunc {
return async (dispatch: DispatchFunc) => {
// Fetch form
if (binding.form?.source) {
const callRequest = createCallRequest(
binding.form.source,
context,
);
const res = await dispatch(doAppFetchForm<Res>(callRequest, intl));
return res;
}
// Open form
if (binding.form) {
// This should come properly formed, but using preventive checks
if (!binding.form?.submit) {
const errMsg = intl.formatMessage({
id: 'apps.error.malformed_binding',
defaultMessage: 'This binding is not properly formed. Contact the App developer.',
});
return {error: makeCallErrorResponse(errMsg)};
}
const res: AppCallResponse = {
type: AppCallResponseTypes.FORM,
form: binding.form,
};
return {data: res};
}
// Submit binding
// This should come properly formed, but using preventive checks
if (!binding.submit) {
const errMsg = intl.formatMessage({
id: 'apps.error.malformed_binding',
defaultMessage: 'This binding is not properly formed. Contact the App developer.',
});
return {error: makeCallErrorResponse(errMsg)};
}
const callRequest = createCallRequest(
binding.submit,
context,
);
const res = await dispatch(doAppSubmit<Res>(callRequest, intl));
return res;
};
}
export function doAppSubmit<Res=unknown>(inCall: AppCallRequest, intl: any): ActionFunc {
return async () => {
try {
const res = await Client4.executeAppCall(call, type) as AppCallResponse<Res>;
const call: AppCallRequest = {
...inCall,
context: {
...inCall.context,
track_as_submit: true,
},
};
const res = await Client4.executeAppCall(call, true) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
@ -22,10 +80,10 @@ export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType,
case AppCallResponseTypes.ERROR:
return {error: res};
case AppCallResponseTypes.FORM: {
if (!res.form) {
if (!res.form?.submit) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.form.no_form',
defaultMessage: 'Response type is `form`, but no form was included in response.',
defaultMessage: 'Response type is `form`, but no valid form was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
@ -43,14 +101,6 @@ export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType,
return {error: 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 {error: makeCallErrorResponse(errMsg)};
}
return {data: res};
default: {
const errMsg = intl.formatMessage({
@ -72,6 +122,73 @@ export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType,
};
}
export function doAppFetchForm<Res=unknown>(call: AppCallRequest, intl: any): ActionFunc {
return async () => {
try {
const res = await Client4.executeAppCall(call, false) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
case AppCallResponseTypes.ERROR:
return {error: res};
case AppCallResponseTypes.FORM:
if (!res.form?.submit) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.form.no_form',
defaultMessage: 'Response type is `form`, but no valid form was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
cleanForm(res.form);
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 {error: makeCallErrorResponse(errMsg)};
}
}
} catch (error: any) {
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
return {error: makeCallErrorResponse(errMsg)};
}
};
}
export function doAppLookup<Res=unknown>(call: AppCallRequest, intl: any): ActionFunc {
return async () => {
try {
const res = await Client4.executeAppCall(call, false) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
case AppCallResponseTypes.OK:
return {data: res};
case AppCallResponseTypes.ERROR:
return {error: res};
default: {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {type: responseType});
return {error: makeCallErrorResponse(errMsg)};
}
}
} catch (error: any) {
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
return {error: makeCallErrorResponse(errMsg)};
}
};
}
export function postEphemeralCallResponseForPost(response: AppCallResponse, message: string, post: Post): ActionFunc {
return (dispatch: DispatchFunc) => {
return dispatch(sendEphemeralPost(

View file

@ -373,7 +373,7 @@ export function showSearchModal(initialValue = '') {
showModal(name, title, passProps, options);
}
export const showAppForm = async (form, call, theme) => {
export const showAppForm = async (form, context, theme) => {
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
let submitButtons;
@ -397,7 +397,7 @@ export const showAppForm = async (form, call, theme) => {
},
};
const passProps = {form, call};
const passProps = {form, context};
showModal('AppForm', form.title, passProps, options);
};

View file

@ -3,11 +3,11 @@
import {intlShape} from 'react-intl';
import {doAppCall, postEphemeralCallResponseForCommandArgs} from '@actions/apps';
import {doAppSubmit, postEphemeralCallResponseForCommandArgs} from '@actions/apps';
import {AppCommandParser} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser';
import {IntegrationTypes} from '@mm-redux/action_types';
import {executeCommand as executeCommandService} from '@mm-redux/actions/integrations';
import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps';
import {AppCallResponseTypes} from '@mm-redux/constants/apps';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {DispatchFunc, GetStateFunc, ActionFunc} from '@mm-redux/types/actions';
import {AppCallResponse} from '@mm-redux/types/apps';
@ -41,19 +41,19 @@ export function executeCommand(message: string, channelId: string, rootId: strin
if (appsAreEnabled) {
const parser = new AppCommandParser({dispatch, getState}, intl, args.channel_id, args.team_id, args.root_id);
if (parser.isAppCommand(msg)) {
const {call, errorMessage} = await parser.composeCallFromCommand(msg);
const {creq, errorMessage} = await parser.composeCommandSubmitCall(msg);
const createErrorMessage = (errMessage: string) => {
return {error: {message: errMessage}};
};
if (!call) {
if (!creq) {
return createErrorMessage(errorMessage!);
}
const res = await dispatch(doAppCall(call, AppCallTypes.SUBMIT, intl));
const res = await dispatch(doAppSubmit(creq, intl));
if (res.error) {
const errorResponse = res.error as AppCallResponse;
return createErrorMessage(errorResponse.error || intl.formatMessage({
return createErrorMessage(errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error.',
}));
@ -61,14 +61,14 @@ export function executeCommand(message: string, channelId: string, rootId: strin
const callResp = res.data as AppCallResponse;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
dispatch(postEphemeralCallResponseForCommandArgs(callResp, callResp.markdown, args));
if (callResp.text) {
dispatch(postEphemeralCallResponseForCommandArgs(callResp, callResp.text, args));
}
return {data: {}};
case AppCallResponseTypes.FORM:
return {data: {
form: callResp.form,
call,
call: creq,
}};
case AppCallResponseTypes.NAVIGATE:
return {data: {

View file

@ -3,21 +3,21 @@
import {buildQueryString} from '@mm-redux/utils/helpers';
import type {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import type {AppBinding, AppCallRequest, AppCallResponse} from '@mm-redux/types/apps';
export interface ClientAppsMix {
executeAppCall: (call: AppCallRequest, type: AppCallType) => Promise<AppCallResponse>;
executeAppCall: (call: AppCallRequest, trackAsSubmit: boolean) => Promise<AppCallResponse>;
getAppsBindings: (userID: string, channelID: string, teamID: string) => Promise<AppBinding[]>;
}
const ClientApps = (superclass: any) => class extends superclass {
executeAppCall = async (call: AppCallRequest, type: AppCallType) => {
executeAppCall = async (call: AppCallRequest, trackAsSubmit: boolean) => {
const callCopy = {
...call,
path: `${call.path}/${type}`,
context: {
...call.context,
user_agent: 'mobile',
track_as_submit: trackAsSubmit,
},
};

View file

@ -10,7 +10,6 @@ import {
} from './app_command_parser';
import {
AppCallResponseTypes,
AppCallTypes,
AutocompleteSuggestion,
} from './app_command_parser_dependencies';
import {
@ -243,7 +242,7 @@ describe('AppCommandParser', () => {
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/create-issue');
expect(parsed.incomplete).toBe('epic2');
expect(parsed.incompleteStart).toBe(75);
expect(parsed.values?.project).toBe('P 1');
@ -254,7 +253,7 @@ describe('AppCommandParser', () => {
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/create-issue');
expect(parsed.values?.project).toBe('P 1');
expect(parsed.values?.epic).toBe('epic2');
expect(parsed.values?.summary).toBe('SUM MA RY');
@ -267,7 +266,7 @@ describe('AppCommandParser', () => {
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/create-issue');
expect(parsed.incomplete).toBe('epic2');
expect(parsed.incompleteStart).toBe(75);
expect(parsed.values?.project).toBe('P 1');
@ -278,7 +277,7 @@ describe('AppCommandParser', () => {
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/create-issue');
expect(parsed.values?.project).toBe('P 1');
expect(parsed.values?.epic).toBe('epic2');
expect(parsed.values?.summary).toBe('SUM MA RY');
@ -291,7 +290,7 @@ describe('AppCommandParser', () => {
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/create-issue');
expect(parsed.incomplete).toBe('M');
expect(parsed.incompleteStart).toBe(65);
expect(parsed.values?.project).toBe('KT');
@ -300,7 +299,7 @@ describe('AppCommandParser', () => {
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/create-issue');
expect(parsed.values?.epic).toBe('M');
}},
},
@ -310,7 +309,7 @@ describe('AppCommandParser', () => {
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/view-issue');
expect(parsed.incomplete).toBe('MM-123');
expect(parsed.incompleteStart).toBe(33);
expect(parsed.values?.project).toBe('P 1');
@ -319,7 +318,7 @@ describe('AppCommandParser', () => {
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/view-issue');
expect(parsed.values?.project).toBe('P 1');
expect(parsed.values?.issue).toBe('MM-123');
}},
@ -330,7 +329,7 @@ describe('AppCommandParser', () => {
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.StartParameter);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/view-issue');
expect(parsed.incomplete).toBe('');
expect(parsed.incompleteStart).toBe(17);
expect(parsed.values).toEqual({});
@ -342,14 +341,14 @@ describe('AppCommandParser', () => {
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.FlagValueSeparator);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/create-issue');
expect(parsed.incomplete).toBe('');
expect(parsed.values).toEqual({});
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/create-issue');
expect(parsed.incomplete).toBe('');
expect(parsed.values).toEqual({
summary: '',
@ -362,7 +361,7 @@ describe('AppCommandParser', () => {
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.TickValue);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/view-issue');
expect(parsed.incomplete).toBe('P 1');
expect(parsed.incompleteStart).toBe(27);
expect(parsed.values?.project).toBe(undefined);
@ -376,7 +375,7 @@ describe('AppCommandParser', () => {
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.QuotedValue);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/view-issue');
expect(parsed.incomplete).toBe('P 1');
expect(parsed.incompleteStart).toBe(27);
expect(parsed.values?.project).toBe(undefined);
@ -390,7 +389,7 @@ describe('AppCommandParser', () => {
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndQuotedValue);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/view-issue');
expect(parsed.incomplete).toBe('P 1');
expect(parsed.incompleteStart).toBe(27);
expect(parsed.values?.project).toBe(undefined);
@ -399,7 +398,7 @@ describe('AppCommandParser', () => {
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndQuotedValue);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.resolvedForm?.submit?.path).toBe('/view-issue');
expect(parsed.values?.project).toBe('P 1');
expect(parsed.values?.issue).toBe(undefined);
}},
@ -923,7 +922,7 @@ describe('AppCommandParser', () => {
});
});
describe('composeCallFromCommand', () => {
describe('composeCommandSubmitCall', () => {
const base = {
context: {
app_id: 'jira',
@ -939,13 +938,9 @@ describe('AppCommandParser', () => {
const cmd = '/jira issue create';
const values = {};
const {call} = await parser.composeCallFromCommand(cmd);
expect(call).toEqual({
const {creq} = await parser.composeCommandSubmitCall(cmd);
expect(creq).toEqual({
...base,
context: {
...base.context,
location: '/command/jira/issue/create',
},
raw_command: cmd,
expand: {},
query: undefined,
@ -962,17 +957,13 @@ describe('AppCommandParser', () => {
label: 'Dylan Epic',
value: 'epic1',
},
verbose: 'true',
verbose: true,
project: '',
};
const {call} = await parser.composeCallFromCommand(cmd);
expect(call).toEqual({
const {creq} = await parser.composeCommandSubmitCall(cmd);
expect(creq).toEqual({
...base,
context: {
...base.context,
location: '/command/jira/issue/create',
},
expand: {},
selected_field: undefined,
query: undefined,
@ -1009,7 +1000,7 @@ describe('AppCommandParser', () => {
team_id: 'team_id',
},
expand: {},
path: '/create-issue',
path: '/create-issue-lookup',
query: 'special',
raw_command: '/jira issue create --summary "The summary" --epic epic1 --project special',
selected_field: 'project',
@ -1020,7 +1011,7 @@ describe('AppCommandParser', () => {
value: 'epic1',
},
},
}, AppCallTypes.LOOKUP);
}, false);
});
});
});

View file

@ -3,18 +3,22 @@
import {intlShape} from 'react-intl';
import {getUserByUsername, getUser, autocompleteUsers} from '@mm-redux/actions/users';
export {doAppFetchForm, doAppLookup} from '@actions/apps';
export {getChannelByNameAndTeamName, getChannel, autocompleteChannels} from '@mm-redux/actions/channels';
import {getUserByUsername, getUser, autocompleteUsers, autocompleteUsersInChannel} from '@mm-redux/actions/users';
export {AppsTypes} from '@mm-redux/action_types';
export {makeAppBindingsSelector, makeRHSAppBindingSelector, getAppCommandForm, getAppRHSCommandForm} from '@mm-redux/selectors/entities/apps';
export {getChannel as selectChannel, getCurrentChannel, getChannelByName as selectChannelByName} from '@mm-redux/selectors/entities/channels';
export {getPost} from '@mm-redux/selectors/entities/posts';
import {getCurrentTeamId, getCurrentTeam} from '@mm-redux/selectors/entities/teams';
import {
ActionFunc,
DispatchFunc,
} from '@mm-redux/types/actions';
import Store from '@store/store';
export {getUserByUsername as selectUserByUsername, getUser as selectUser} from '@mm-redux/selectors/entities/users';
import {DispatchFunc} from '@mm-redux/types/actions';
import ReduxStore from '@store/store';
import type {ParsedCommand} from './app_command_parser';
import type {AutocompleteSuggestion} from '@mm-redux/types/integrations';
export type {
AppCall,
AppCallRequest,
AppBinding,
AppField,
@ -29,36 +33,22 @@ export type {
AutocompleteUserSelect,
AutocompleteChannelSelect,
AppLookupResponse,
UserAutocomplete,
} from '@mm-redux/types/apps';
export type {
DoAppCallResult,
} from 'types/actions/apps';
export {AppsTypes} from '@mm-redux/action_types';
export type {AutocompleteSuggestion};
export type {
Channel,
} from '@mm-redux/types/channels';
export type {
GlobalState,
} from '@mm-redux/types/store';
export type {Channel} from '@mm-redux/types/channels';
import type {AutocompleteSuggestion} from '@mm-redux/types/integrations';
import type {GlobalState} from '@mm-redux/types/store';
export type {
DispatchFunc,
GlobalState,
};
export type {
UserProfile,
} from '@mm-redux/types/users';
export type {AutocompleteSuggestion};
export type {DoAppCallResult} from 'types/actions/apps';
export {
AppBindingLocations,
AppCallTypes,
AppFieldTypes,
AppCallResponseTypes,
COMMAND_SUGGESTION_ERROR,
@ -66,43 +56,37 @@ export {
COMMAND_SUGGESTION_USER,
} from '@mm-redux/constants/apps';
export {makeAppBindingsSelector, makeRHSAppBindingSelector, getAppCommandForm, getAppRHSCommandForm} from '@mm-redux/selectors/entities/apps';
export {getPost} from '@mm-redux/selectors/entities/posts';
export {getChannel as selectChannel, getCurrentChannel, getChannelByName as selectChannelByName} from '@mm-redux/selectors/entities/channels';
export {
getCurrentTeamId,
getCurrentTeam,
};
export {getUserByUsername as selectUserByUsername, getUser as selectUser} from '@mm-redux/selectors/entities/users';
export {
getUserByUsername,
getUser,
autocompleteUsers,
autocompleteUsersInChannel,
};
export {getChannelByNameAndTeamName, getChannel, autocompleteChannels} from '@mm-redux/actions/channels';
export {doAppCall} from '@actions/apps';
export {
createCallRequest,
filterEmptyOptions,
} from '@utils/apps';
export const getStore = () => Store.redux;
export interface Store {
dispatch: DispatchFunc;
getState: () => GlobalState;
}
export const autocompleteUsersInChannel = (prefix: string, channelID: string): ActionFunc => {
return async (dispatch, getState) => {
const state = getState();
const currentTeamID = getCurrentTeamId(state);
return dispatch(autocompleteUsers(prefix, currentTeamID, channelID));
};
};
export const getStore = () => ReduxStore.redux as Store;
export const EXECUTE_CURRENT_COMMAND_ITEM_ID = '_execute_current_command';
export const OPEN_COMMAND_IN_MODAL_ITEM_ID = '_open_command_in_modal';
export const getOpenInModalSuggestion = (_: ParsedCommand): AutocompleteSuggestion | null => { // eslint-disable-line @typescript-eslint/no-unused-vars
// Not supported on mobile yet
return null;
};
export type ExtendedAutocompleteSuggestion = AutocompleteSuggestion & {
type?: string;

View file

@ -27,7 +27,6 @@ export const reduxTestState = {
display_name: 'Default',
delete_at: 0,
type: 'O',
total_msg_count: 10,
team_id: 'team_id',
},
current_user_id__existingId: {
@ -36,13 +35,16 @@ export const reduxTestState = {
display_name: 'Default',
delete_at: 0,
type: '0',
total_msg_count: 0,
team_id: 'team_id',
},
},
channelsInTeam: {
'team-id': ['current_channel_id'],
},
messageCounts: {
current_channel_id: {total: 10},
current_user_id__existingId: {total: 0},
},
},
teams: {
currentTeamId: 'team-id',
@ -103,7 +105,7 @@ export const viewCommand: AppBinding = {
location: '/command/jira/issue/view',
description: 'View details of a Jira issue',
form: {
call: {
submit: {
path: '/view-issue',
},
fields: [
@ -114,6 +116,9 @@ export const viewCommand: AppBinding = {
type: AppFieldTypes.DYNAMIC_SELECT,
hint: 'The Jira project hint',
is_required: true,
lookup: {
path: '/view-issue-lookup',
},
},
{
name: 'issue',
@ -135,7 +140,7 @@ export const createCommand: AppBinding = {
icon: 'Create icon',
hint: 'Create hint',
form: {
call: {
submit: {
path: '/create-issue',
},
fields: [
@ -145,6 +150,9 @@ export const createCommand: AppBinding = {
description: 'The Jira project description',
type: AppFieldTypes.DYNAMIC_SELECT,
hint: 'The Jira project hint',
lookup: {
path: '/create-issue-lookup',
},
},
{
name: 'summary',
@ -189,7 +197,7 @@ export const restCommand: AppBinding = {
icon: 'rest icon',
hint: 'rest hint',
form: {
call: {
submit: {
path: '/create-issue',
},
fields: [
@ -252,6 +260,9 @@ export const testBindings: AppBinding[] = [
label: 'sub1',
description: 'Some Description',
form: {
submit: {
path: '/submit_other',
},
fields: [{
name: 'fieldname',
label: 'fieldlabel',

View file

@ -313,7 +313,7 @@ export default class DraftInput extends PureComponent {
}
if (data.form) {
showAppForm(data.form, data.call, theme);
showAppForm(data.form, data.call.context, theme);
}
this.setInputValue('');

View file

@ -6,13 +6,13 @@ import {intlShape, injectIntl} from 'react-intl';
import Button from 'react-native-button';
import {showAppForm} from '@actions/navigation';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {AppBindingLocations, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {AppBinding} from '@mm-redux/types/apps';
import {Post} from '@mm-redux/types/posts';
import {Theme} from '@mm-redux/types/theme';
import {DoAppCall, PostEphemeralCallResponseForPost} from '@mm-types/actions/apps';
import {createCallContext, createCallRequest} from '@utils/apps';
import {HandleBindingClick, PostEphemeralCallResponseForPost} from '@mm-types/actions/apps';
import {createCallContext} from '@utils/apps';
import {getStatusColors} from '@utils/message_attachment_colors';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
@ -21,7 +21,7 @@ import ButtonBindingText from './button_binding_text';
type Props = {
binding: AppBinding;
doAppCall: DoAppCall;
handleBindingClick: HandleBindingClick;
intl: typeof intlShape;
post: Post;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
@ -53,7 +53,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const ButtonBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseForPost, teamID, theme, handleGotoLocation}: Props) => {
const ButtonBinding = ({binding, handleBindingClick, intl, post, postEphemeralCallResponseForPost, teamID, theme, handleGotoLocation}: Props) => {
const pressed = useRef(false);
const style = getStyleSheet(theme);
@ -62,12 +62,6 @@ const ButtonBinding = ({binding, doAppCall, intl, post, postEphemeralCallRespons
return;
}
const call = binding.form?.call || binding.call;
if (!call) {
return;
}
const context = createCallContext(
binding.app_id,
AppBindingLocations.IN_POST + binding.location,
@ -76,25 +70,14 @@ const ButtonBinding = ({binding, doAppCall, intl, post, postEphemeralCallRespons
post.id,
);
const callRequest = createCallRequest(
call,
context,
{post: AppExpandLevels.EXPAND_ALL},
);
if (binding.form) {
showAppForm(binding.form, callRequest, theme);
return;
}
pressed.current = true;
const res = await doAppCall(callRequest, AppCallTypes.SUBMIT, intl);
const res = await handleBindingClick(binding, context, intl);
pressed.current = false;
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.error || intl.formatMessage({
const errorMessage = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
@ -106,15 +89,15 @@ const ButtonBinding = ({binding, doAppCall, intl, post, postEphemeralCallRespons
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
if (callResp.text) {
postEphemeralCallResponseForPost(callResp, callResp.text, post);
}
return;
case AppCallResponseTypes.NAVIGATE:
handleGotoLocation(callResp.navigate_to_url!, intl);
return;
case AppCallResponseTypes.FORM:
showAppForm(callResp.form, call, theme);
showAppForm(callResp.form, context, theme);
return;
default: {
const errorMessage = intl.formatMessage({

View file

@ -3,7 +3,7 @@
import {connect} from 'react-redux';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
import {handleBindingClick, postEphemeralCallResponseForPost} from '@actions/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {getChannel} from '@mm-redux/selectors/entities/channels';
import {getPost} from '@mm-redux/selectors/entities/posts';
@ -32,7 +32,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
}
const mapDispatchToProps = {
doAppCall,
handleBindingClick,
postEphemeralCallResponseForPost,
handleGotoLocation,
};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useState, useEffect} from 'react';
import {View} from 'react-native';
import {AppBindingLocations} from '@mm-redux/constants/apps';
@ -39,9 +39,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
});
const EmbeddedBinding = ({embed, postId, theme}: Props) => {
const style = getStyleSheet(theme);
const [cleanedBindings, setCleanedBindings] = useState<AppBinding[]>([]);
const bindings = cleanBinding(embed, AppBindingLocations.IN_POST)?.bindings;
useEffect(() => {
const copiedBindings = JSON.parse(JSON.stringify(embed)) as AppBinding;
const bindings = cleanBinding(copiedBindings, AppBindingLocations.IN_POST)?.bindings;
setCleanedBindings(bindings!);
}, [embed]);
const style = getStyleSheet(theme);
return (
<>
@ -58,9 +64,9 @@ const EmbeddedBinding = ({embed, postId, theme}: Props) => {
theme={theme}
/>
}
{Boolean(bindings?.length) &&
{Boolean(cleanedBindings?.length) &&
<EmbedSubBindings
bindings={bindings!}
bindings={cleanedBindings}
postId={postId}
theme={theme}
/>

View file

@ -19,7 +19,7 @@ const EmbeddedSubBindings = ({bindings, postId, theme}: Props) => {
const content = [] as React.ReactNode[];
bindings.forEach((binding) => {
if (!binding.app_id || !binding.call) {
if (!binding.app_id || !(binding.submit || binding.form?.submit || binding.form?.source || binding.bindings?.length)) {
return;
}

View file

@ -3,7 +3,7 @@
import {connect} from 'react-redux';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
import {handleBindingClick, postEphemeralCallResponseForPost} from '@actions/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {getChannel} from '@mm-redux/selectors/entities/channels';
import {getPost} from '@mm-redux/selectors/entities/posts';
@ -32,7 +32,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
}
const mapDispatchToProps = {
doAppCall,
handleBindingClick,
postEphemeralCallResponseForPost,
handleGotoLocation,
};

View file

@ -6,19 +6,19 @@ import {intlShape, injectIntl} from 'react-intl';
import {showAppForm} from '@actions/navigation';
import AutocompleteSelector from '@components/autocomplete_selector';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {AppBindingLocations, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {Theme} from '@mm-redux/types/theme';
import {createCallContext, createCallRequest} from '@utils/apps';
import {createCallContext} from '@utils/apps';
import type {AppBinding} from '@mm-redux/types/apps';
import type {PostActionOption} from '@mm-redux/types/integration_actions';
import type {Post} from '@mm-redux/types/posts';
import type {DoAppCall, PostEphemeralCallResponseForPost} from '@mm-types/actions/apps';
import type {HandleBindingClick, PostEphemeralCallResponseForPost} from '@mm-types/actions/apps';
type Props = {
binding: AppBinding;
doAppCall: DoAppCall;
handleBindingClick: HandleBindingClick;
intl: typeof intlShape;
post: Post;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
@ -27,7 +27,7 @@ type Props = {
theme: Theme;
}
const MenuBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseForPost, handleGotoLocation, teamID, theme}: Props) => {
const MenuBinding = ({binding, handleBindingClick, intl, post, postEphemeralCallResponseForPost, handleGotoLocation, teamID, theme}: Props) => {
const [selected, setSelected] = useState<PostActionOption>();
const onSelect = useCallback(async (picked?: PostActionOption) => {
@ -42,12 +42,6 @@ const MenuBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseF
return;
}
const call = bind.form?.call || bind.call;
if (!call) {
return;
}
const context = createCallContext(
bind.app_id,
AppBindingLocations.IN_POST + bind.location,
@ -56,21 +50,10 @@ const MenuBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseF
post.id,
);
const callRequest = createCallRequest(
call,
context,
{post: AppExpandLevels.EXPAND_ALL},
);
if (bind.form) {
showAppForm(bind.form, callRequest);
return;
}
const res = await doAppCall(callRequest, AppCallTypes.SUBMIT, intl);
const res = await handleBindingClick(bind, context, intl);
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.error || intl.formatMessage({
const errorMessage = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
@ -81,15 +64,15 @@ const MenuBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseF
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
if (callResp.text) {
postEphemeralCallResponseForPost(callResp, callResp.text, post);
}
return;
case AppCallResponseTypes.NAVIGATE:
handleGotoLocation(callResp.navigate_to_url!, intl);
return;
case AppCallResponseTypes.FORM:
showAppForm(callResp.form, call, theme);
showAppForm(callResp.form, context, theme);
return;
default: {
const errorMessage = intl.formatMessage({

View file

@ -29,7 +29,7 @@ const contentType: Record<string, string> = {
};
const Content = ({isReplyPost, post, theme}: ContentProps) => {
let type: string = post.metadata?.embeds[0]?.type;
let type: string = post.metadata?.embeds?.[0]?.type;
if (!type && post.props?.app_bindings) {
type = contentType.app_bindings;
}

View file

@ -3,6 +3,7 @@
import {Client4} from '@client/rest';
import {analytics} from '@init/analytics';
import {UserTypes, TeamTypes} from '@mm-redux/action_types';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {getCurrentUserId, getUsers} from '@mm-redux/selectors/entities/users';
import {Action, ActionFunc, ActionResult, batchActions, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions';
import {TeamMembership} from '@mm-redux/types/teams';
@ -741,6 +742,14 @@ export function autocompleteUsers(term: string, teamId = '', channelId = '', opt
};
}
export function autocompleteUsersInChannel(prefix: string, channelID: string): ActionFunc {
return async (dispatch, getState) => {
const state = getState();
const currentTeamID = getCurrentTeamId(state);
return dispatch(autocompleteUsers(prefix, currentTeamID, channelID));
};
}
export function searchProfiles(term: string, options: any = {}): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const {currentUserId} = getState().entities.users;

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppCallResponseType, AppCallType, AppExpandLevel, AppFieldType} from '@mm-redux/types/apps';
import {AppCallResponseType, AppExpandLevel, AppFieldType} from '@mm-redux/types/apps';
export const AppBindingLocations = {
POST_MENU_ITEM: '/post_menu',
@ -22,13 +22,6 @@ export const AppCallResponseTypes: { [name: string]: AppCallResponseType } = {
NAVIGATE: 'navigate',
};
export const AppCallTypes: { [name: string]: AppCallType } = {
SUBMIT: 'submit',
LOOKUP: 'lookup',
FORM: 'form',
CANCEL: 'cancel',
};
export const AppExpandLevels: { [name: string]: AppExpandLevel } = {
EXPAND_DEFAULT: '',
EXPAND_NONE: 'none',

View file

@ -7,7 +7,11 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "a",
"location": "/post_menu/locA",
},
@ -19,7 +23,11 @@ Array [
"bindings": Array [
Object {
"app_id": "2",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "a",
"location": "/post_menu/locA",
},
@ -31,14 +39,22 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"icon": "icon",
"label": "b",
"location": "/channel_header/locB",
},
Object {
"app_id": "1",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "c",
"location": "/channel_header/locC",
},
@ -50,14 +66,11 @@ Array [
"bindings": Array [
Object {
"app_id": "2",
"call": Object {},
"icon": "icon",
"label": "locB",
"location": "/channel_header/locB",
},
Object {
"app_id": "2",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"icon": "icon",
"label": "c",
"location": "/channel_header/locC",
@ -70,13 +83,21 @@ Array [
"bindings": Array [
Object {
"app_id": "3",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "locB",
"location": "/channel_header/locB",
},
Object {
"app_id": "3",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "c",
"location": "/channel_header/locC",
},
@ -88,7 +109,11 @@ Array [
"bindings": Array [
Object {
"app_id": "3",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "c",
"location": "/command/locC",
},
@ -105,13 +130,21 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "a",
"location": "/post_menu/locA",
},
Object {
"app_id": "1",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "a",
"location": "/post_menu/locB",
},
@ -123,7 +156,11 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"icon": "icon",
"label": "b",
"location": "/channel_header/locB",
@ -139,13 +176,11 @@ Array [
"bindings": Array [
Object {
"app_id": "3",
"call": Object {},
"label": "subC1",
"location": "/command/locC/subC1",
},
Object {
"app_id": "3",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "c2",
"location": "/command/locC/subC2",
},
@ -153,31 +188,6 @@ Array [
"label": "c",
"location": "/command/locC",
},
Object {
"app_id": "3",
"bindings": Array [
Object {
"app_id": "3",
"call": Object {},
"label": "subC1",
"location": "/command/locD/subC1",
},
],
"label": "d",
"location": "/command/locD",
},
],
"location": "/command",
},
Object {
"app_id": "1",
"bindings": Array [
Object {
"app_id": "1",
"call": Object {},
"label": "locC",
"location": "/command/locC",
},
],
"location": "/command",
},
@ -189,18 +199,25 @@ Array [
"bindings": Array [
Object {
"app_id": "2",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "c1",
"location": "/command/locC/subC1",
},
Object {
"app_id": "2",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "c2",
"location": "/command/locC/subC2",
},
],
"call": Object {},
"label": "c",
"location": "/command/locC",
},
@ -217,13 +234,11 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"call": Object {},
"label": "locA",
"location": "/post_menu/locA",
},
Object {
"app_id": "1",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "a",
"location": "/post_menu/locB",
},
@ -235,37 +250,37 @@ Array [
"bindings": Array [
Object {
"app_id": "2",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "a",
"location": "/post_menu/locA",
},
Object {
"app_id": "2",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "b",
"location": "/post_menu/locB",
},
],
"location": "/post_menu",
},
Object {
"app_id": "3",
"bindings": Array [
Object {
"app_id": "3",
"call": Object {},
"label": "locA",
"location": "/post_menu/locA",
},
],
"location": "/post_menu",
},
Object {
"app_id": "1",
"bindings": Array [
Object {
"app_id": "1",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"icon": "icon",
"label": "b",
"location": "/channel_header/locB",
@ -278,7 +293,11 @@ Array [
"bindings": Array [
Object {
"app_id": "3",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "c",
"location": "/command/locC",
},
@ -295,7 +314,11 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "a",
"location": "/post_menu/locA",
},
@ -307,7 +330,11 @@ Array [
"bindings": Array [
Object {
"app_id": "2",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "a",
"location": "/post_menu/locA",
},
@ -319,7 +346,11 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"icon": "icon",
"label": "b",
"location": "/channel_header/locB",
@ -332,7 +363,11 @@ Array [
"bindings": Array [
Object {
"app_id": "3",
"call": Object {},
"form": Object {
"submit": Object {
"path": "/submit_url",
},
},
"label": "c",
"location": "/command/locC",
},

View file

@ -7,7 +7,11 @@ import * as Reducers from './apps';
describe('bindings', () => {
const initialState = [];
const basicSubmitForm = {
submit: {
path: '/submit_url',
},
};
test('No element get filtered', () => {
const data = [
{
@ -17,7 +21,7 @@ describe('bindings', () => {
{
location: 'locA',
label: 'a',
call: {},
form: basicSubmitForm,
},
],
},
@ -28,7 +32,7 @@ describe('bindings', () => {
{
location: 'locA',
label: 'a',
call: {},
form: basicSubmitForm,
},
],
},
@ -40,7 +44,7 @@ describe('bindings', () => {
location: 'locB',
label: 'b',
icon: 'icon',
call: {},
form: basicSubmitForm,
},
],
},
@ -51,7 +55,7 @@ describe('bindings', () => {
{
location: 'locC',
label: 'c',
call: {},
form: basicSubmitForm,
},
],
},
@ -77,7 +81,7 @@ describe('bindings', () => {
{
location: 'locA',
label: 'a',
call: {},
form: basicSubmitForm,
},
],
},
@ -88,7 +92,7 @@ describe('bindings', () => {
{
location: 'locA',
label: 'a',
call: {},
form: basicSubmitForm,
},
],
},
@ -100,12 +104,12 @@ describe('bindings', () => {
location: 'locB',
label: 'b',
icon: 'icon',
call: {},
form: basicSubmitForm,
},
{
location: 'locC',
label: 'c',
call: {},
form: basicSubmitForm,
},
],
},
@ -114,15 +118,14 @@ describe('bindings', () => {
location: '/channel_header',
bindings: [
{
location: 'locB',
icon: 'icon',
call: {},
form: basicSubmitForm,
},
{
location: 'locC',
label: 'c',
icon: 'icon',
call: {},
form: basicSubmitForm,
},
],
},
@ -132,12 +135,12 @@ describe('bindings', () => {
bindings: [
{
location: 'locB',
call: {},
form: basicSubmitForm,
},
{
location: 'locC',
label: 'c',
call: {},
form: basicSubmitForm,
},
],
},
@ -148,7 +151,7 @@ describe('bindings', () => {
{
location: 'locC',
label: 'c',
call: {},
form: basicSubmitForm,
},
],
},
@ -172,13 +175,12 @@ describe('bindings', () => {
location: '/post_menu',
bindings: [
{
location: 'locA',
call: {},
form: basicSubmitForm,
},
{
location: 'locB',
label: 'a',
call: {},
form: basicSubmitForm,
},
],
},
@ -189,12 +191,12 @@ describe('bindings', () => {
{
location: 'locA',
label: 'a',
call: {},
form: basicSubmitForm,
},
{
location: 'locB',
label: 'b',
call: {},
form: basicSubmitForm,
},
],
},
@ -203,8 +205,7 @@ describe('bindings', () => {
location: '/post_menu',
bindings: [
{
location: 'locA',
call: {},
form: basicSubmitForm,
},
],
},
@ -216,7 +217,7 @@ describe('bindings', () => {
location: 'locB',
label: 'b',
icon: 'icon',
call: {},
form: basicSubmitForm,
},
],
},
@ -227,7 +228,7 @@ describe('bindings', () => {
{
location: 'locC',
label: 'c',
call: {},
form: basicSubmitForm,
},
],
},
@ -253,12 +254,12 @@ describe('bindings', () => {
{
location: 'locA',
label: 'a',
call: {},
form: basicSubmitForm,
},
{
location: 'locB',
label: 'a',
call: {},
form: basicSubmitForm,
},
],
},
@ -270,7 +271,7 @@ describe('bindings', () => {
location: 'locB',
label: 'b',
icon: 'icon',
call: {},
form: basicSubmitForm,
},
],
},
@ -283,13 +284,12 @@ describe('bindings', () => {
label: 'c',
bindings: [
{
location: 'subC1',
call: {},
form: basicSubmitForm,
},
{
location: 'subC2',
label: 'c2',
call: {},
form: basicSubmitForm,
},
],
},
@ -298,8 +298,7 @@ describe('bindings', () => {
label: 'd',
bindings: [
{
location: 'subC1',
call: {},
form: basicSubmitForm,
},
],
},
@ -310,8 +309,7 @@ describe('bindings', () => {
location: '/command',
bindings: [
{
location: 'locC',
call: {},
form: basicSubmitForm,
},
],
},
@ -322,17 +320,16 @@ describe('bindings', () => {
{
location: 'locC',
label: 'c',
call: {},
bindings: [
{
location: 'subC1',
label: 'c1',
call: {},
form: basicSubmitForm,
},
{
location: 'subC2',
label: 'c2',
call: {},
form: basicSubmitForm,
},
],
},

View file

@ -8,7 +8,6 @@ export type AppManifest = {
display_name: string;
description?: string;
homepage_url?: string;
root_url: string;
}
export type AppModalState = {
@ -50,19 +49,17 @@ export type AppBinding = {
depends_on_user?: boolean;
depends_on_post?: boolean;
// A Binding is either to a Call, or is a "container" for other locations -
// i.e. menu sub-items or subcommands.
call?: AppCall;
// A Binding is either an action (makes a call), a Form, or is a
// "container" for other locations - i.e. menu sub-items or subcommands.
bindings?: AppBinding[];
form?: AppForm;
submit?: AppCall;
};
export type AppCallValues = {
[name: string]: any;
};
export type AppCallType = string;
export type AppCall = {
path: string;
expand?: AppExpand;
@ -81,9 +78,8 @@ export type AppCallResponseType = string;
export type AppCallResponse<Res = unknown> = {
type: AppCallResponseType;
markdown?: string;
text?: string;
data?: Res;
error?: string;
navigate_to_url?: string;
use_external_browser?: boolean;
call?: AppCall;
@ -107,6 +103,7 @@ export type AppContext = {
root_id?: string;
props?: AppContextProps;
user_agent?: string;
track_as_submit?: boolean;
};
export type AppContextProps = {
@ -136,8 +133,19 @@ export type AppForm = {
submit_buttons?: string;
cancel_button?: boolean;
submit_on_cancel?: boolean;
fields: AppField[];
call?: AppCall;
fields?: AppField[];
// source is used in 2 cases:
// - if submit is not set, it is used to fetch the submittable form from
// the app.
// - if a select field change triggers a refresh, the form is refreshed
// from source.
source?: AppCall;
// submit is called when one of the submit buttons is pressed, or the
// command is executed.
submit?: AppCall;
depends_on?: string[];
};
@ -176,6 +184,7 @@ export type AppField = {
refresh?: boolean;
options?: AppSelectOption[];
multiselect?: boolean;
lookup?: AppCall;
// Text props
subtype?: string;

View file

@ -17,14 +17,11 @@ describe('AppsForm', () => {
submit: jest.fn(),
handleGotoLocation: jest.fn(),
},
call: {
context: {
app_id: 'app1',
},
path: '/create',
},
componentId: '',
form: {
submit: {
path: '/create',
},
title: 'Title',
footer: 'Footer',
header: 'Header',

View file

@ -14,7 +14,7 @@ import Markdown from '@components/markdown';
import StatusBar from '@components/status_bar';
import {AppCallResponseTypes, AppFieldTypes} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {AppCallRequest, AppField, AppForm, AppFormValue, AppFormValues, AppLookupResponse, AppSelectOption, FormResponseData} from '@mm-redux/types/apps';
import {AppField, AppForm, AppFormValue, AppFormValues, AppLookupResponse, AppSelectOption, FormResponseData} from '@mm-redux/types/apps';
import {DialogElement} from '@mm-redux/types/integrations';
import {Theme} from '@mm-redux/types/theme';
import {checkDialogElementForError, checkIfErrorsMatchElements} from '@mm-redux/utils/integration_utils';
@ -29,7 +29,6 @@ import AppsFormField from './apps_form_field';
import DialogIntroductionText from './dialog_introduction_text';
export type Props = {
call: AppCallRequest;
form: AppForm;
actions: {
submit: (submission: {
@ -155,7 +154,7 @@ export default class AppsFormComponent extends PureComponent<Props, State> {
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.error;
const errorMessage = errorResponse.text;
const hasErrors = this.updateErrors(elements, errorResponse.data?.errors, errorMessage);
if (!hasErrors) {
this.handleHide();
@ -225,7 +224,7 @@ export default class AppsFormComponent extends PureComponent<Props, State> {
performLookup = async (name: string, userInput: string): Promise<AppSelectOption[]> => {
const intl = this.context.intl;
const field = this.props.form.fields.find((f) => f.name === name);
const field = this.props.form.fields?.find((f) => f.name === name);
if (!field) {
return [];
}
@ -233,7 +232,7 @@ export default class AppsFormComponent extends PureComponent<Props, State> {
const res = await this.props.actions.performLookupCall(field, this.state.values, userInput);
if (res.error) {
const errorResponse = res.error;
const errMsg = errorResponse.error || intl.formatMessage({
const errMsg = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error.',
});
@ -294,7 +293,7 @@ export default class AppsFormComponent extends PureComponent<Props, State> {
};
onChange = (name: string, value: any) => {
const field = this.props.form.fields.find((f) => f.name === name);
const field = this.props.form.fields?.find((f) => f.name === name);
if (!field) {
return;
}
@ -305,7 +304,7 @@ export default class AppsFormComponent extends PureComponent<Props, State> {
this.props.actions.refreshOnSelect(field, values, value).then((res) => {
if (res.error) {
const errorResponse = res.error;
const errorMsg = errorResponse.error;
const errorMsg = errorResponse.text;
const errors = errorResponse.data?.errors;
const elements = fieldsAsElements(this.props.form.fields);
this.updateErrors(elements, errors, errorMsg);

View file

@ -4,20 +4,22 @@
import React, {PureComponent} from 'react';
import {intlShape} from 'react-intl';
import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps';
import {AppCallResponseTypes} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {AppCallResponse, AppCallRequest, AppField, AppForm, AppFormValues, FormResponseData, AppLookupResponse} from '@mm-redux/types/apps';
import {AppCallResponse, AppField, AppForm, AppFormValues, FormResponseData, AppLookupResponse, AppContext} from '@mm-redux/types/apps';
import {Theme} from '@mm-redux/types/theme';
import {DoAppCall, DoAppCallResult, PostEphemeralCallResponseForContext} from '@mm-types/actions/apps';
import {makeCallErrorResponse} from '@utils/apps';
import {DoAppCallResult, DoAppFetchForm, DoAppLookup, DoAppSubmit, PostEphemeralCallResponseForContext} from '@mm-types/actions/apps';
import {createCallRequest, makeCallErrorResponse} from '@utils/apps';
import AppsFormComponent from './apps_form_component';
export type Props = {
form?: AppForm;
call?: AppCallRequest;
context?: AppContext;
actions: {
doAppCall: DoAppCall<any>;
doAppSubmit: DoAppSubmit<any>;
doAppFetchForm: DoAppFetchForm<any>;
doAppLookup: DoAppLookup<any>;
postEphemeralCallResponseForContext: PostEphemeralCallResponseForContext;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
};
@ -61,20 +63,21 @@ export default class AppsFormContainer extends PureComponent<Props, State> {
)))};
}
const call = this.getCall();
if (!call) {
if (!form.submit) {
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage(
{
id: 'apps.error.form.no_call',
defaultMessage: '`call` is not defined',
id: 'apps.error.form.no_submit',
defaultMessage: '`submit` is not defined',
},
)))};
}
const res = await this.props.actions.doAppCall({
...call,
values: submission.values,
}, AppCallTypes.SUBMIT, intl);
if (!this.props.context) {
return {error: makeCallErrorResponse('unreachable: empty context')};
}
const creq = createCallRequest(form.submit, this.props.context, {}, submission.values);
const res = await this.props.actions.doAppSubmit(creq, intl) as DoAppCallResult<FormResponseData>;
if (res.error) {
return res;
@ -83,8 +86,8 @@ export default class AppsFormContainer extends PureComponent<Props, State> {
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
this.props.actions.postEphemeralCallResponseForContext(callResp, callResp.markdown, call.context);
if (callResp.text) {
this.props.actions.postEphemeralCallResponseForContext(callResp, callResp.text, creq.context);
}
break;
case AppCallResponseTypes.FORM:
@ -122,11 +125,10 @@ export default class AppsFormContainer extends PureComponent<Props, State> {
})))};
}
const call = this.getCall();
if (!call) {
if (!form.source) {
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({
id: 'apps.error.form.no_call',
defaultMessage: '`call` is not defined.',
id: 'apps.error.form.no_source',
defaultMessage: '`source` is not defined.',
})))};
}
@ -138,12 +140,14 @@ export default class AppsFormContainer extends PureComponent<Props, State> {
})))};
}
const res = await this.props.actions.doAppCall({
...call,
selected_field: field.name,
values,
if (!this.props.context) {
return {error: makeCallErrorResponse('unreachable: empty context')};
}
}, AppCallTypes.FORM, intl);
const creq = createCallRequest(form.source, this.props.context, {}, values);
creq.selected_field = field.name;
const res = await this.props.actions.doAppFetchForm(creq, intl);
if (res.error) {
return res;
@ -183,54 +187,33 @@ export default class AppsFormContainer extends PureComponent<Props, State> {
},
{details: message},
);
const call = this.getCall();
if (!call) {
return makeErrorMsg(intl.formatMessage({id: 'apps.error.form.no_lookup_call', defaultMessage: 'performLookupCall props.call is not defined'}));
if (!field.lookup) {
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({
id: 'apps.error.form.no_lookup',
defaultMessage: '`lookup` is not defined.',
})))};
}
return this.props.actions.doAppCall({
...call,
values,
selected_field: field.name,
query: userInput,
}, AppCallTypes.LOOKUP, intl);
};
getCall = (): AppCallRequest | null => {
const {form} = this.state;
const {call} = this.props;
if (!call) {
return null;
if (!this.props.context) {
return {error: makeCallErrorResponse('unreachable: empty context')};
}
return {
...call,
...form?.call,
context: {
...call.context,
},
values: {
...call.values,
},
};
const creq = createCallRequest(field.lookup, this.props.context, {}, values);
creq.selected_field = field.name;
creq.query = userInput;
return this.props.actions.doAppLookup(creq, intl);
};
render() {
const {form} = this.state;
if (!form) {
return null;
}
const call = this.getCall();
if (!call) {
if (!form?.submit || !this.props.context) {
return null;
}
return (
<AppsFormComponent
form={form}
call={call}
actions={{
submit: this.handleSubmit,
performLookupCall: this.performLookupCall,

View file

@ -4,17 +4,19 @@
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {doAppCall, postEphemeralCallResponseForContext} from '@actions/apps';
import {doAppFetchForm, doAppLookup, doAppSubmit, postEphemeralCallResponseForContext} from '@actions/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {ActionFunc, GenericAction} from '@mm-redux/types/actions';
import {GlobalState} from '@mm-redux/types/store';
import {DoAppCall, PostEphemeralCallResponseForContext} from '@mm-types/actions/apps';
import {DoAppFetchForm, DoAppLookup, DoAppSubmit, PostEphemeralCallResponseForContext} from '@mm-types/actions/apps';
import AppsFormContainer from './apps_form_container';
type Actions = {
doAppCall: DoAppCall;
doAppSubmit: DoAppSubmit<any>;
doAppFetchForm: DoAppFetchForm<any>;
doAppLookup: DoAppLookup<any>;
postEphemeralCallResponseForContext: PostEphemeralCallResponseForContext;
};
@ -27,7 +29,9 @@ function mapStateToProps(state: GlobalState) {
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
doAppSubmit,
doAppFetchForm,
doAppLookup,
postEphemeralCallResponseForContext,
handleGotoLocation,
}, dispatch),

View file

@ -6,14 +6,14 @@ import {intlShape, injectIntl} from 'react-intl';
import {Alert} from 'react-native';
import {dismissModal, showAppForm} from '@actions/navigation';
import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps';
import {AppCallResponseTypes} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {AppBinding} from '@mm-redux/types/apps';
import {Channel} from '@mm-redux/types/channels';
import {Theme} from '@mm-redux/types/theme';
import {DoAppCall, PostEphemeralCallResponseForChannel} from '@mm-types/actions/apps';
import {HandleBindingClick, PostEphemeralCallResponseForChannel} from '@mm-types/actions/apps';
import Separator from '@screens/channel_info/separator';
import {createCallContext, createCallRequest} from '@utils/apps';
import {createCallContext} from '@utils/apps';
import ChannelInfoRow from '../channel_info_row';
@ -25,7 +25,7 @@ type Props = {
intl: typeof intlShape;
currentTeamId: string;
actions: {
doAppCall: DoAppCall;
handleBindingClick: HandleBindingClick;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
};
@ -70,7 +70,7 @@ type OptionProps = {
intl: typeof intlShape;
currentTeamId: string;
actions: {
doAppCall: DoAppCall;
handleBindingClick: HandleBindingClick;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
};
@ -87,38 +87,22 @@ class Option extends React.PureComponent<OptionProps, OptionState> {
onPress = async () => {
const {binding, currentChannel, currentTeamId, intl, theme} = this.props;
const {doAppCall, postEphemeralCallResponseForChannel} = this.props.actions;
const {handleBindingClick, postEphemeralCallResponseForChannel} = this.props.actions;
if (this.state.submitting) {
return;
}
const call = binding.form?.call || binding.call;
if (!call) {
return;
}
const context = createCallContext(
binding.app_id,
binding.location,
currentChannel.id,
currentChannel.team_id || currentTeamId,
);
const callRequest = createCallRequest(
call,
context,
);
if (binding.form) {
await dismissModal();
showAppForm(binding.form, callRequest, theme);
return;
}
this.setState({submitting: true});
const res = await doAppCall(callRequest, AppCallTypes.SUBMIT, intl);
const res = await handleBindingClick(binding, context, intl);
this.setState({submitting: false});
@ -128,7 +112,7 @@ class Option extends React.PureComponent<OptionProps, OptionState> {
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errorMessage = errorResponse.error || intl.formatMessage({
const errorMessage = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
@ -139,8 +123,8 @@ class Option extends React.PureComponent<OptionProps, OptionState> {
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
postEphemeralCallResponseForChannel(callResp, callResp.markdown, currentChannel.id);
if (callResp.text) {
postEphemeralCallResponseForChannel(callResp, callResp.text, currentChannel.id);
}
break;
case AppCallResponseTypes.NAVIGATE:
@ -149,7 +133,7 @@ class Option extends React.PureComponent<OptionProps, OptionState> {
return;
case AppCallResponseTypes.FORM:
await dismissModal();
showAppForm(callResp.form, call, theme);
showAppForm(callResp.form, context, theme);
return;
default: {
const title = intl.formatMessage({

View file

@ -4,7 +4,7 @@
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {doAppCall, postEphemeralCallResponseForChannel} from '@actions/apps';
import {handleBindingClick, postEphemeralCallResponseForChannel} from '@actions/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {AppBindingLocations} from '@mm-redux/constants/apps';
import {makeAppBindingsSelector} from '@mm-redux/selectors/entities/apps';
@ -13,7 +13,7 @@ import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {GenericAction, ActionFunc} from '@mm-redux/types/actions';
import {AppBinding} from '@mm-redux/types/apps';
import {GlobalState} from '@mm-redux/types/store';
import {DoAppCall, PostEphemeralCallResponseForChannel} from '@mm-types/actions/apps';
import {HandleBindingClick, PostEphemeralCallResponseForChannel} from '@mm-types/actions/apps';
import {appsEnabled} from '@utils/apps';
import Bindings from './bindings';
@ -35,14 +35,14 @@ function mapStateToProps(state: GlobalState) {
}
type Actions = {
doAppCall: DoAppCall;
handleBindingClick: HandleBindingClick;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
handleBindingClick,
postEphemeralCallResponseForChannel,
handleGotoLocation,
}, dispatch),

View file

@ -4,18 +4,18 @@
import React, {useState, useEffect} from 'react';
import {intlShape, injectIntl} from 'react-intl';
import {Alert} from 'react-native';
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
import {HandleBindingClick, PostEphemeralCallResponseForPost} from 'types/actions/apps';
import {showAppForm} from '@actions/navigation';
import {Client4} from '@client/rest';
import {AppBindingLocations, AppCallResponseTypes, AppCallTypes, AppExpandLevels} from '@mm-redux/constants/apps';
import {AppBindingLocations, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {AppBinding, AppCallResponse} from '@mm-redux/types/apps';
import {Post} from '@mm-redux/types/posts';
import {Theme} from '@mm-redux/types/theme';
import {UserProfile} from '@mm-redux/types/users';
import {isSystemMessage} from '@mm-redux/utils/post_utils';
import {createCallContext, createCallRequest} from '@utils/apps';
import {createCallContext} from '@utils/apps';
import PostOption from '../post_option';
@ -29,7 +29,7 @@ type Props = {
appsEnabled: boolean;
intl: typeof intlShape;
actions: {
doAppCall: DoAppCall;
handleBindingClick: HandleBindingClick;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
};
@ -101,7 +101,7 @@ type OptionProps = {
closeWithAnimation: (cb?: () => void) => void;
intl: typeof intlShape;
actions: {
doAppCall: DoAppCall;
handleBindingClick: HandleBindingClick;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
};
@ -110,13 +110,7 @@ type OptionProps = {
class Option extends React.PureComponent<OptionProps> {
onPress = async () => {
const {post, teamID, binding, intl, theme} = this.props;
const {doAppCall, postEphemeralCallResponseForPost} = this.props.actions;
const call = binding.form?.call || binding.call;
if (!call) {
return;
}
const {handleBindingClick, postEphemeralCallResponseForPost} = this.props.actions;
const context = createCallContext(
binding.app_id,
@ -126,20 +120,8 @@ class Option extends React.PureComponent<OptionProps> {
post.id,
post.root_id,
);
const callRequest = createCallRequest(
call,
context,
{
post: AppExpandLevels.ALL,
},
);
if (binding.form) {
showAppForm(binding.form, callRequest, theme);
return;
}
const callPromise = doAppCall(callRequest, AppCallTypes.SUBMIT, intl);
const callPromise = handleBindingClick(binding, context, intl);
await this.close();
const res = await callPromise;
@ -149,7 +131,7 @@ class Option extends React.PureComponent<OptionProps> {
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errorMessage = errorResponse.error || intl.formatMessage({
const errorMessage = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
@ -160,15 +142,15 @@ class Option extends React.PureComponent<OptionProps> {
const callResp = (res as {data: AppCallResponse}).data;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
if (callResp.text) {
postEphemeralCallResponseForPost(callResp, callResp.text, post);
}
break;
case AppCallResponseTypes.NAVIGATE:
this.props.actions.handleGotoLocation(callResp.navigate_to_url!, intl);
break;
case AppCallResponseTypes.FORM:
showAppForm(callResp.form, callRequest, theme);
showAppForm(callResp.form, context, theme);
break;
default: {
const title = intl.formatMessage({

View file

@ -4,7 +4,7 @@
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
import {handleBindingClick, postEphemeralCallResponseForPost} from '@actions/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {AppBindingLocations} from '@mm-redux/constants/apps';
import {getThreadAppsBindingsChannelId, makeAppBindingsSelector, makeRHSAppBindingSelector} from '@mm-redux/selectors/entities/apps';
@ -16,7 +16,7 @@ import {GenericAction, ActionFunc} from '@mm-redux/types/actions';
import {AppBinding} from '@mm-redux/types/apps';
import {Post} from '@mm-redux/types/posts';
import {GlobalState} from '@mm-redux/types/store';
import {DoAppCall, PostEphemeralCallResponseForPost} from '@mm-types/actions/apps';
import {HandleBindingClick, PostEphemeralCallResponseForPost} from '@mm-types/actions/apps';
import {appsEnabled} from '@utils/apps';
import Bindings from './bindings';
@ -56,14 +56,14 @@ function mapStateToProps(state: GlobalState, props: OwnProps) {
}
type Actions = {
doAppCall: DoAppCall;
handleBindingClick: HandleBindingClick;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
handleBindingClick,
postEphemeralCallResponseForPost,
handleGotoLocation,
}, dispatch),

View file

@ -7,72 +7,70 @@ import {AppBinding, AppCall, AppField, AppForm, AppSelectOption} from '@mm-redux
import {cleanForm, cleanBinding} from './apps';
describe('Apps Utils', () => {
describe('cleanBindings', () => {
test('Apps IDs, and Calls propagate down, and locations get formed', () => {
const basicCall: AppCall = {
path: 'url',
};
const basicSubmitForm: AppForm = {
submit: basicCall,
};
const basicFetchForm: AppForm = {
source: basicCall,
};
describe('fillAndTrimBindingsInformation', () => {
test('Apps IDs propagate down, and locations get formed', () => {
const inBinding: AppBinding = {
app_id: 'id',
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc2',
bindings: [
{
location: 'loc3',
} as AppBinding,
submit: basicCall,
},
{
location: 'loc4',
} as AppBinding,
form: basicSubmitForm,
},
],
} as AppBinding,
},
{
location: 'loc5',
} as AppBinding,
form: basicFetchForm,
},
],
} as AppBinding;
const outBinding: AppBinding = {
app_id: 'id',
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2',
app_id: 'id',
label: 'loc2',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2/loc3',
app_id: 'id',
label: 'loc3',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
submit: basicCall,
},
{
location: 'loc1/loc2/loc4',
app_id: 'id',
label: 'loc4',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
form: basicSubmitForm,
},
],
} as AppBinding,
},
{
location: 'loc1/loc5',
app_id: 'id',
label: 'loc5',
call: {
path: 'url',
} as AppCall,
form: basicFetchForm,
},
],
} as AppBinding;
@ -81,138 +79,6 @@ describe('Apps Utils', () => {
expect(inBinding).toEqual(outBinding);
});
test('Do not overwrite calls nor ids on the way down.', () => {
const inBinding: AppBinding = {
app_id: 'id',
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
app_id: 'id2',
location: 'loc2',
bindings: [
{
location: 'loc3',
} as AppBinding,
{
location: 'loc4',
} as AppBinding,
],
} as AppBinding,
{
call: {
path: 'url2',
} as AppCall,
location: 'loc5',
} as AppBinding,
],
} as AppBinding;
const outBinding: AppBinding = {
app_id: 'id',
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2',
app_id: 'id2',
label: 'loc2',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2/loc3',
app_id: 'id2',
label: 'loc3',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
{
location: 'loc1/loc2/loc4',
app_id: 'id2',
label: 'loc4',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
],
} as AppBinding,
{
location: 'loc1/loc5',
app_id: 'id',
label: 'loc5',
call: {
path: 'url2',
} as AppCall,
},
],
} as AppBinding;
cleanBinding(inBinding, '');
expect(inBinding).toEqual(outBinding);
});
test('Trim branches without app_id.', () => {
const inBinding: AppBinding = {
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc2',
bindings: [
{
location: 'loc3',
} as AppBinding,
{
app_id: 'id',
location: 'loc4',
} as AppBinding,
],
} as AppBinding,
{
location: 'loc5',
} as AppBinding,
],
} as AppBinding;
const outBinding: AppBinding = {
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2',
label: 'loc2',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2/loc4',
app_id: 'id',
label: 'loc4',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
],
} as AppBinding,
],
} as AppBinding;
cleanBinding(inBinding, '');
expect(inBinding).toEqual(outBinding);
});
test('Trim branches without call.', () => {
const inBinding: AppBinding = {
location: 'loc1',
@ -223,18 +89,28 @@ describe('Apps Utils', () => {
bindings: [
{
location: 'loc3',
} as AppBinding,
},
{
location: 'loc4',
call: {
path: 'url',
},
} as AppBinding,
form: basicSubmitForm,
},
{
location: 'loc5',
form: basicFetchForm,
},
],
} as AppBinding,
},
{
location: 'loc5',
} as AppBinding,
location: 'loc6',
},
{
location: 'loc7',
submit: basicCall,
},
{
location: 'loc8',
form: {},
},
],
} as AppBinding;
@ -250,84 +126,140 @@ describe('Apps Utils', () => {
{
location: 'loc1/loc2/loc4',
app_id: 'id',
call: {
path: 'url',
},
form: basicSubmitForm,
label: 'loc4',
} as AppBinding,
},
{
location: 'loc1/loc2/loc5',
app_id: 'id',
form: basicFetchForm,
label: 'loc5',
},
],
} as AppBinding,
},
{
location: 'loc1/loc7',
app_id: 'id',
submit: basicCall,
label: 'loc7',
},
],
} as AppBinding;
cleanBinding(inBinding, '');
expect(inBinding).toEqual(outBinding);
});
test('Trim mixed invalid branches.', () => {
test('Trim branches with calls, bindings and forms.', () => {
const inBinding: AppBinding = {
location: 'loc1',
app_id: 'id',
bindings: [
{
location: 'loc2',
bindings: [
{
location: 'loc3',
} as AppBinding,
submit: basicCall,
form: basicSubmitForm,
},
{
location: 'loc4',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
form: basicSubmitForm,
},
{
location: 'loc5',
form: basicFetchForm,
},
],
} as AppBinding,
},
{
app_id: 'id',
location: 'loc5',
} as AppBinding,
location: 'loc6',
submit: basicCall,
bindings: [
{
location: 'loc9',
submit: basicCall,
},
],
},
{
location: 'loc7',
submit: basicCall,
},
{
location: 'loc8',
form: basicFetchForm,
bindings: [
{
location: 'loc10',
submit: basicCall,
},
],
},
],
} as AppBinding;
const outBinding: AppBinding = {
app_id: 'id',
location: 'loc1',
bindings: [] as AppBinding[],
bindings: [
{
location: 'loc1/loc2',
app_id: 'id',
label: 'loc2',
bindings: [
{
location: 'loc1/loc2/loc4',
app_id: 'id',
form: basicSubmitForm,
label: 'loc4',
},
{
location: 'loc1/loc2/loc5',
app_id: 'id',
form: basicFetchForm,
label: 'loc5',
},
],
},
{
location: 'loc1/loc7',
app_id: 'id',
submit: basicCall,
label: 'loc7',
},
],
} as AppBinding;
cleanBinding(inBinding, '');
expect(inBinding).toEqual(outBinding);
});
test('Do not filter bindings with no call but with a form with a call', () => {
const inBinding = {
app_id: 'appID',
test('Trim branches with whitespace labels.', () => {
const inBinding: AppBinding = {
location: 'loc1',
app_id: 'id',
bindings: [
{
location: 'loc2',
form: {
call: {
path: 'url',
label: ' ',
bindings: [
{
location: 'loc4',
form: basicSubmitForm,
},
},
} as AppBinding,
{
location: 'loc5',
form: basicFetchForm,
},
],
},
],
} as AppBinding;
const outBinding = {
app_id: 'appID',
const outBinding: AppBinding = {
app_id: 'id',
location: 'loc1',
bindings: [
{
app_id: 'appID',
location: 'loc1/loc2',
label: 'loc2',
form: {
call: {
path: 'url',
},
},
},
],
bindings: [] as AppBinding[],
} as AppBinding;
cleanBinding(inBinding, '');
@ -424,7 +356,7 @@ describe('Apps Utils', () => {
cleanForm(inForm);
expect(inForm).toEqual(outForm);
});
test('field filter with same label inerred from name', () => {
test('field filter with same label inferred from name', () => {
const inForm: AppForm = {
fields: [
{
@ -814,6 +746,58 @@ describe('Apps Utils', () => {
],
};
cleanForm(inForm);
expect(inForm).toEqual(outForm);
});
test('field filter dynamic with no valid lookup call', () => {
const inForm: AppForm = {
fields: [
{
name: 'opt1',
type: AppFieldTypes.DYNAMIC_SELECT,
lookup: basicCall,
},
{
name: 'opt2',
type: AppFieldTypes.DYNAMIC_SELECT,
},
],
};
const outForm: AppForm = {
fields: [
{
name: 'opt1',
type: AppFieldTypes.DYNAMIC_SELECT,
lookup: basicCall,
},
],
};
cleanForm(inForm);
expect(inForm).toEqual(outForm);
});
test('invalid dynamic field does not consume namespace', () => {
const inForm: AppForm = {
fields: [
{
name: 'field1',
type: AppFieldTypes.DYNAMIC_SELECT,
},
{
name: 'field1',
type: AppFieldTypes.TEXT,
},
],
};
const outForm: AppForm = {
fields: [
{
name: 'field1',
type: AppFieldTypes.TEXT,
},
],
};
cleanForm(inForm);
expect(inForm).toEqual(outForm);
});
@ -832,17 +816,13 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc11',
label: 'loc11',
call: {
path: '/path',
},
submit: basicCall,
},
{
app_id: 'app',
location: 'loc12',
label: 'loc12',
call: {
path: '/path',
},
form: basicSubmitForm,
},
],
},
@ -850,9 +830,7 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc2',
label: 'loc2',
call: {
path: '/path',
},
form: basicFetchForm,
},
],
} as AppBinding;
@ -868,17 +846,13 @@ describe('Apps Utils', () => {
app_id: 'app',
location: '/command/loc1/loc11',
label: 'loc11',
call: {
path: '/path',
},
submit: basicCall,
},
{
app_id: 'app',
location: '/command/loc1/loc12',
label: 'loc12',
call: {
path: '/path',
},
form: basicSubmitForm,
},
],
},
@ -886,9 +860,7 @@ describe('Apps Utils', () => {
app_id: 'app',
location: '/command/loc2',
label: 'loc2',
call: {
path: '/path',
},
form: basicFetchForm,
},
],
} as AppBinding;
@ -909,14 +881,18 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc11',
label: 'loc11',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
{
app_id: 'app',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
} as AppBinding,
],
@ -925,8 +901,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc2',
label: 'loc2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -943,8 +921,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: '/command/loc1/loc11',
label: 'loc11',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -953,8 +933,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: '/command/loc2',
label: 'loc2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -976,15 +958,19 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc11',
label: 'loc11',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
{
app_id: 'app',
location: 'loc1 2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
} as AppBinding,
],
@ -993,8 +979,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc2',
label: 'loc2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1011,8 +999,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: '/command/loc1/loc11',
label: 'loc11',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1021,8 +1011,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: '/command/loc2',
label: 'loc2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1044,16 +1036,20 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc11',
label: 'loc11',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
{
app_id: 'app',
location: 'loc12',
label: 'loc1 2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1062,8 +1058,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc2',
label: 'loc2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1080,8 +1078,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: '/command/loc1/loc11',
label: 'loc11',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1090,8 +1090,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: '/command/loc2',
label: 'loc2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1113,16 +1115,20 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'same',
description: 'loc11',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
} as AppBinding,
{
app_id: 'app',
location: 'same',
description: 'loc12',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
} as AppBinding,
],
@ -1131,8 +1137,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc2',
label: 'loc2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1150,8 +1158,10 @@ describe('Apps Utils', () => {
location: '/command/loc1/same',
label: 'same',
description: 'loc11',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
} as AppBinding,
],
@ -1160,8 +1170,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: '/command/loc2',
label: 'loc2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1183,16 +1195,20 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc11',
label: 'same',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
{
app_id: 'app',
location: 'loc12',
label: 'same',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1201,8 +1217,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc2',
label: 'loc2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1219,8 +1237,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: '/command/loc1/loc11',
label: 'same',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1229,8 +1249,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: '/command/loc2',
label: 'loc2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1252,8 +1274,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc11',
label: 'loc 1 1',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1262,8 +1286,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: 'loc2',
label: 'loc2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],
@ -1275,8 +1301,10 @@ describe('Apps Utils', () => {
app_id: 'app',
location: '/command/loc2',
label: 'loc2',
call: {
path: '/path',
form: {
submit: {
path: '/path',
},
},
},
],

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes} from '@mm-redux/constants/apps';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {AppBinding, AppCall, AppCallRequest, AppCallValues, AppContext, AppExpand, AppField, AppForm, AppSelectOption} from '@mm-redux/types/apps';
import {AppBinding, AppCall, AppCallRequest, AppCallResponse, AppCallValues, AppContext, AppExpand, AppField, AppForm, AppSelectOption} from '@mm-redux/types/apps';
import {Config} from '@mm-redux/types/config';
import {GlobalState} from '@mm-redux/types/store';
@ -24,16 +24,6 @@ function cleanBindingRec(binding: AppBinding, topLocation: string, depth: number
const usedLabels: {[label: string]: boolean} = {};
binding.bindings?.forEach((b, i) => {
// Inheritance and defaults
if (!b.call && binding.call) {
b.call = binding.call;
}
if (b.form) {
cleanForm(b.form);
} else if (binding.form) {
b.form = binding.form;
}
if (!b.app_id) {
b.app_id = binding.app_id;
}
@ -45,7 +35,13 @@ function cleanBindingRec(binding: AppBinding, topLocation: string, depth: number
b.location = binding.location + '/' + b.location;
// Validation
if (!b.label) {
if (!b.app_id) {
toRemove.unshift(i);
return;
}
// No empty labels nor "whitespace" labels
if (!b.label.trim()) {
toRemove.unshift(i);
return;
}
@ -72,7 +68,19 @@ function cleanBindingRec(binding: AppBinding, topLocation: string, depth: number
}
}
if (b.bindings?.length) {
// Must have only subbindings, a form or a submit call.
const hasBindings = Boolean(b.bindings?.length);
const hasForm = Boolean(b.form);
const hasSubmit = Boolean(b.submit);
if ((!hasBindings && !hasForm && !hasSubmit) ||
(hasBindings && hasForm) ||
(hasBindings && hasSubmit) ||
(hasForm && hasSubmit)) {
toRemove.unshift(i);
return;
}
if (hasBindings) {
cleanBindingRec(b, topLocation, depth + 1);
// Remove invalid branches
@ -80,18 +88,13 @@ function cleanBindingRec(binding: AppBinding, topLocation: string, depth: number
toRemove.unshift(i);
return;
}
} else {
// Remove leaves without a call
if (!b.call && !b.form?.call) {
} else if (hasForm) {
if (!b.form?.submit && !b.form?.source) {
toRemove.unshift(i);
return;
}
// Remove leaves without app id
if (!b.app_id) {
toRemove.unshift(i);
return;
}
cleanForm(b.form);
}
usedLabels[b.label] = true;
@ -150,19 +153,26 @@ export function cleanForm(form?: AppForm): void {
return;
}
if (field.type === AppFieldTypes.STATIC_SELECT) {
switch (field.type) {
case AppFieldTypes.STATIC_SELECT:
cleanStaticSelect(field);
if (!field.options?.length) {
toRemove.unshift(i);
return;
}
break;
case AppFieldTypes.DYNAMIC_SELECT:
if (!field.lookup) {
toRemove.unshift(i);
return;
}
}
usedLabels[label] = true;
});
toRemove.forEach((i) => {
form.fields.splice(i, 1);
form.fields!.splice(i, 1);
});
}
@ -224,8 +234,6 @@ export function createCallRequest(
defaultExpand: AppExpand = {},
values?: AppCallValues,
rawCommand?: string,
query?: string,
selectedField?: string,
): AppCallRequest {
return {
...call,
@ -236,15 +244,13 @@ export function createCallRequest(
...call.expand,
},
raw_command: rawCommand,
query,
selected_field: selectedField,
};
}
export const makeCallErrorResponse = (errMessage: string) => {
export const makeCallErrorResponse = (errMessage: string): AppCallResponse<any> => {
return {
type: AppCallResponseTypes.ERROR,
error: errMessage,
text: errMessage,
};
};

View file

@ -1,18 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
autocompleteChannels,
AutocompleteSuggestion,
autocompleteUsersInChannel,
Channel,
COMMAND_SUGGESTION_CHANNEL,
COMMAND_SUGGESTION_USER,
DispatchFunc,
GlobalState,
UserAutocomplete,
UserProfile,
} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies';
import {autocompleteChannels} from '@mm-redux/actions/channels';
import {autocompleteUsersInChannel} from '@mm-redux/actions/users';
import {COMMAND_SUGGESTION_CHANNEL, COMMAND_SUGGESTION_USER} from '@mm-redux/constants/apps';
import {Channel} from '@mm-redux/types/channels';
import type {DispatchFunc} from '@mm-redux/types/actions';
import type {UserAutocomplete} from '@mm-redux/types/apps';
import type {AutocompleteSuggestion} from '@mm-redux/types/integrations';
import type {GlobalState} from '@mm-redux/types/store';
import type {UserProfile} from '@mm-redux/types/users';
interface Store {
dispatch: DispatchFunc;

View file

@ -16,35 +16,45 @@
"api.channel.guest_join_channel.post_and_forget": "{username} joined the channel as a guest.",
"apps.error": "Error: {error}",
"apps.error.command.field_missing": "Required fields missing: `{fieldName}`.",
"apps.error.command.same_channel": "Channel repeated for field `{fieldName}`: `{option}`.",
"apps.error.command.same_option": "Option repeated for field `{fieldName}`: `{option}`.",
"apps.error.command.same_user": "User repeated for field `{fieldName}`: `{option}`.",
"apps.error.command.unknown_channel": "Unknown channel for field `{fieldName}`: `{option}`.",
"apps.error.command.unknown_option": "Unknown option for field `{fieldName}`: `{option}`.",
"apps.error.command.unknown_user": "Unknown user for field `{fieldName}`: `{option}`.",
"apps.error.form.no_call": "`call` is not defined.",
"apps.error.form.no_form": "`form` is not defined.",
"apps.error.form.no_lookup_call": "performLookupCall props.call is not defined",
"apps.error.form.no_lookup": "`lookup` is not defined.",
"apps.error.form.no_source": "`source` is not defined.",
"apps.error.form.no_submit": "`submit` is not defined",
"apps.error.form.refresh": "There has been an error fetching the select fields. Contact the app developer. Details: {details}",
"apps.error.form.refresh_no_refresh": "Called refresh on no refresh field.",
"apps.error.form.submit.pretext": "There has been an error submitting the modal. Contact the app developer. Details: {details}",
"apps.error.lookup.error_preparing_request": "Error preparing lookup request: {errorMessage}",
"apps.error.malformed_binding": "This binding is not properly formed. Contact the App developer.",
"apps.error.parser": "Parsing error: {error}",
"apps.error.parser.empty_value": "empty values are not allowed",
"apps.error.parser.execute_non_leaf": "You must select a subcommand.",
"apps.error.parser.missing_binding": "Missing command bindings.",
"apps.error.parser.missing_call": "Missing binding call.",
"apps.error.parser.missing_field_value": "Field value is missing.",
"apps.error.parser.missing_list_end": "Expected list closing token.",
"apps.error.parser.missing_quote": "Matching double quote expected before end of input.",
"apps.error.parser.missing_source": "Form has neither submit nor source.",
"apps.error.parser.missing_submit": "No submit call in binding or form.",
"apps.error.parser.missing_tick": "Matching tick quote expected before end of input.",
"apps.error.parser.multiple_equal": "Multiple `=` signs are not allowed.",
"apps.error.parser.no_argument_pos_x": "Unable to identify argument.",
"apps.error.parser.no_bindings": "No command bindings.",
"apps.error.parser.no_form": "No form found.",
"apps.error.parser.no_match": "`{command}`: No matching command found in this workspace.",
"apps.error.parser.no_slash_start": "Command must start with a `/`.",
"apps.error.parser.unexpected_character": "Unexpected character.",
"apps.error.parser.unexpected_comma": "Unexpected comma.",
"apps.error.parser.unexpected_error": "Unexpected error.",
"apps.error.parser.unexpected_flag": "Command does not accept flag `{flagName}`.",
"apps.error.parser.unexpected_squared_bracket": "Unexpected list opening.",
"apps.error.parser.unexpected_state": "Unreachable: Unexpected state in matchBinding: `{state}`.",
"apps.error.parser.unexpected_whitespace": "Unreachable: Unexpected whitespace.",
"apps.error.responses.form.no_form": "Response type is `form`, but no form was included in response.",
"apps.error.responses.navigate.no_submit": "Response type is `navigate`, but the call was not a submission.",
"apps.error.responses.navigate.no_url": "Response type is `navigate`, but no url was included in response.",
"apps.error.responses.unexpected_error": "Received an unexpected error.",
"apps.error.responses.unexpected_type": "App response type was not expected. Response type: {type}.",

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppCallRequest, AppCallResponse, AppCallType, AppContext} from '@mm-redux/types/apps';
import {AppCallRequest, AppCallResponse, AppContext} from '@mm-redux/types/apps';
import {Post} from '@mm-redux/types/posts';
export type DoAppCallResult<Res=unknown> = {
@ -9,10 +9,6 @@ export type DoAppCallResult<Res=unknown> = {
error?: AppCallResponse<Res>;
}
export interface DoAppCall<Res=unknown> {
(call: AppCallRequest, type: AppCallType, intl: any): Promise<DoAppCallResult<Res>>;
}
export interface PostEphemeralCallResponseForPost {
(response: AppCallResponse, message: string, post: Post): void;
}
@ -24,3 +20,19 @@ export interface PostEphemeralCallResponseForChannel {
export interface PostEphemeralCallResponseForContext {
(response: AppCallResponse, message: string, context: AppContext): void;
}
export interface HandleBindingClick<Res=unknown> {
(binding: AppBinding, context: AppContext, intl: any): Promise<DoAppCallResult<Res>>;
}
export interface DoAppSubmit<Res=unknown> {
(call: AppCallRequest, intl: any): Promise<DoAppCallResult<Res>>;
}
export interface DoAppFetchForm<Res=unknown> {
(call: AppCallRequest, intl: any): Promise<DoAppCallResult<Res>>;
}
export interface DoAppLookup<Res=unknown> {
(call: AppCallRequest, intl: any): Promise<DoAppCallResult<Res>>;
}