diff --git a/app/actions/apps.ts b/app/actions/apps.ts index 8d1dee7dc..9a473322c 100644 --- a/app/actions/apps.ts +++ b/app/actions/apps.ts @@ -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(call: AppCallRequest, type: AppCallType, intl: any): ActionFunc { +export function handleBindingClick(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(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(callRequest, intl)); + return res; + }; +} + +export function doAppSubmit(inCall: AppCallRequest, intl: any): ActionFunc { return async () => { try { - const res = await Client4.executeAppCall(call, type) as AppCallResponse; + const call: AppCallRequest = { + ...inCall, + context: { + ...inCall.context, + track_as_submit: true, + }, + }; + const res = await Client4.executeAppCall(call, true) as AppCallResponse; const responseType = res.type || AppCallResponseTypes.OK; switch (responseType) { @@ -22,10 +80,10 @@ export function doAppCall(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(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(call: AppCallRequest, type: AppCallType, }; } +export function doAppFetchForm(call: AppCallRequest, intl: any): ActionFunc { + return async () => { + try { + const res = await Client4.executeAppCall(call, false) as AppCallResponse; + 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(call: AppCallRequest, intl: any): ActionFunc { + return async () => { + try { + const res = await Client4.executeAppCall(call, false) as AppCallResponse; + 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( diff --git a/app/actions/navigation/index.js b/app/actions/navigation/index.js index 5da0ac0bf..3a12ea32c 100644 --- a/app/actions/navigation/index.js +++ b/app/actions/navigation/index.js @@ -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); }; diff --git a/app/actions/views/command.ts b/app/actions/views/command.ts index 3d8eef076..463c979fc 100644 --- a/app/actions/views/command.ts +++ b/app/actions/views/command.ts @@ -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: { diff --git a/app/client/rest/apps.ts b/app/client/rest/apps.ts index 66aa06733..e38345745 100644 --- a/app/client/rest/apps.ts +++ b/app/client/rest/apps.ts @@ -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; + executeAppCall: (call: AppCallRequest, trackAsSubmit: boolean) => Promise; getAppsBindings: (userID: string, channelID: string, teamID: string) => Promise; } 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, }, }; diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts index 1d9c8dc7a..2a04f16b1 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts @@ -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); }); }); }); diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts index c804e257c..a7ff663fb 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts @@ -7,6 +7,7 @@ import { AppsTypes, AppCallRequest, AppBinding, + AppCall, AppField, DoAppCallResult, AppLookupResponse, @@ -17,18 +18,17 @@ import { AutocompleteSuggestion, AutocompleteStaticSelect, Channel, - DispatchFunc, - GlobalState, + Store, AppBindingLocations, AppCallResponseTypes, - AppCallTypes, AppFieldTypes, makeAppBindingsSelector, selectChannel, getChannel, getCurrentTeamId, - doAppCall, + doAppFetchForm, + doAppLookup, getStore, EXECUTE_CURRENT_COMMAND_ITEM_ID, COMMAND_SUGGESTION_ERROR, @@ -45,6 +45,8 @@ import { filterEmptyOptions, autocompleteUsersInChannel, autocompleteChannels, + getOpenInModalSuggestion, + OPEN_COMMAND_IN_MODAL_ITEM_ID, getChannelSuggestions, getUserSuggestions, inTextMentionSuggestions, @@ -54,11 +56,6 @@ import { makeRHSAppBindingSelector, } from './app_command_parser_dependencies'; -export interface Store { - dispatch: DispatchFunc; - getState: () => GlobalState; -} - export enum ParseState { Start = 'Start', Command = 'Command', @@ -77,11 +74,21 @@ export enum ParseState { EndQuotedValue = 'EndQuotedValue', EndTickedValue = 'EndTickedValue', Error = 'Error', + MultiselectStart = 'MultiselectStart', + MultiselectStartValue = 'MultiselectStartValue', + MultiselectNonspaceValue = 'MultiselectNonspaceValue', + MultiselectQuotedValue = 'MultiselectQuotedValue', + MultiselectTickValue = 'MultiselectTickValue', + MultiselectEndValue = 'MultiselectEndValue', + MultiselectEndQuotedValue = 'MultiselectEndQuotedValue', + MultiselectEndTickedValue = 'MultiselectEndTickedValue', + MultiselectValueSeparator = 'MultiselectValueSeparator', + MultiselectNextValue = 'MultiselectNextValue', Rest = 'Rest', } interface FormsCache { - getForm: (location: string, binding: AppBinding) => Promise<{form?: AppForm; error?: string} | undefined>; + getSubmittableForm: (location: string, binding: AppBinding) => Promise<{form?: AppForm; error?: string} | undefined>; } interface Intl { @@ -98,11 +105,11 @@ export class ParsedCommand { incomplete = ''; incompleteStart = 0; binding: AppBinding | undefined; - form: AppForm | undefined; + resolvedForm: AppForm | undefined; formsCache: FormsCache; field: AppField | undefined; position = 0; - values: {[name: string]: string} = {}; + values: {[name: string]: string | string[]} = {}; location = ''; error = ''; intl: Intl; @@ -243,28 +250,46 @@ export class ParsedCommand { } if (!this.binding.bindings?.length) { - this.form = this.binding?.form; - if (!this.form) { - const fetched = await this.formsCache.getForm(this.location, this.binding); - if (fetched?.error) { - return this.asError(fetched.error); + // No more sub-bindings, must be a submit or a form. + if (this.binding.submit && !this.binding.form) { + // Submit, no form in the binding, construct an empty form for + // submission. + this.resolvedForm = { + submit: this.binding.submit, + }; + } else if (this.binding.form && !this.binding.submit) { + // Form, no submit in the binding. Refresh the form from the + // source/cache as needed. + const form = this.binding.form; + if (!form.submit) { + const fetched = await this.formsCache.getSubmittableForm(this.location, this.binding); + if (fetched?.error) { + return this.asError(fetched.error); + } + this.resolvedForm = fetched?.form; } - this.form = fetched?.form; + this.resolvedForm = this.binding?.form; + } else { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser', + defaultMessage: 'Parsing error: {error}', + }, { + error: 'unreachable: invalid binding, neither or both Submit and Form', + })); } } - return this; }; // parseForm parses the rest of the command using the previously matched form. public parseForm = (autocompleteMode = false): ParsedCommand => { - if (this.state === ParseState.Error || !this.form) { + if (this.state === ParseState.Error || !this.resolvedForm) { return this; } let fields: AppField[] = []; - if (this.form.fields) { - fields = this.form.fields; + if (this.resolvedForm.fields) { + fields = this.resolvedForm.fields; } fields = fields.filter((f) => f.type !== AppFieldTypes.MARKDOWN && !f.readonly); @@ -465,6 +490,16 @@ export class ParsedCommand { id: 'apps.error.parser.unexpected_whitespace', defaultMessage: 'Unreachable: Unexpected whitespace.', })); + case '[': + if (!this.field?.multiselect) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_squared_bracket', + defaultMessage: 'Unexpected list opening.', + })); + } + this.state = ParseState.MultiselectStart; + this.i++; + break; default: { this.state = ParseState.NonspaceValue; break; @@ -595,6 +630,230 @@ export class ParsedCommand { } break; } + + case ParseState.MultiselectStart: + if (!this.field) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_field_value', + defaultMessage: 'Field value is missing.', + })); + } + + this.values![this.field.name] = []; + switch (c) { + case ' ': + case '\t': + this.i++; + break; + case ']': + this.i++; + this.state = ParseState.ParameterSeparator; + break; + default: + this.state = ParseState.MultiselectStartValue; + break; + } + break; + + case ParseState.MultiselectStartValue: + this.incomplete = ''; + this.incompleteStart = this.i; + switch (c) { + case '': + if (!autocompleteMode) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_list_end', + defaultMessage: 'Expected list closing token.', + })); + } + return this; + case '"': { + this.state = ParseState.MultiselectQuotedValue; + this.i++; + break; + } + case '`': { + this.state = ParseState.MultiselectTickValue; + this.i++; + break; + } + case ' ': + case '\t': + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_whitespace', + defaultMessage: 'Unreachable: Unexpected whitespace.', + })); + case ',': + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_comma', + defaultMessage: 'Unexpected comma.', + })); + default: { + this.state = ParseState.MultiselectNonspaceValue; + break; + } + } + break; + + case ParseState.MultiselectNonspaceValue: { + switch (c) { + case '': + case ' ': + case '\t': + case ',': + case ']': { + this.state = ParseState.MultiselectEndValue; + break; + } + default: { + this.incomplete += c; + this.i++; + break; + } + } + break; + } + + case ParseState.MultiselectQuotedValue: { + switch (c) { + case '': { + if (!autocompleteMode) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_quote', + defaultMessage: 'Matching double quote expected before end of input.', + })); + } + return this; + } + case '"': { + if (this.incompleteStart === this.i - 1) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.empty_value', + defaultMessage: 'empty values are not allowed', + })); + } + this.i++; + this.state = ParseState.MultiselectEndQuotedValue; + break; + } + case '\\': { + escaped = true; + this.i++; + break; + } + default: { + this.incomplete += c; + this.i++; + if (escaped) { + //TODO: handle \n, \t, other escaped chars + escaped = false; + } + break; + } + } + break; + } + + case ParseState.MultiselectTickValue: { + switch (c) { + case '': { + if (!autocompleteMode) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_tick', + defaultMessage: 'Matching tick quote expected before end of input.', + })); + } + return this; + } + case '`': { + if (this.incompleteStart === this.i - 1) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.empty_value', + defaultMessage: 'empty values are not allowed', + })); + } + this.i++; + this.state = ParseState.MultiselectEndTickedValue; + break; + } + default: { + this.incomplete += c; + this.i++; + break; + } + } + break; + } + + case ParseState.MultiselectEndTickedValue: + case ParseState.MultiselectEndQuotedValue: + case ParseState.MultiselectEndValue: { + if (!this.field) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_field_value', + defaultMessage: 'Field value is missing.', + })); + } + + if (autocompleteMode && c === '') { + return this; + } + (this.values![this.field.name] as string[]).push(this.incomplete); + this.incomplete = ''; + this.incompleteStart = this.i; + if (c === '') { + return this; + } + this.state = ParseState.MultiselectValueSeparator; + break; + } + + case ParseState.MultiselectValueSeparator: + switch (c) { + case '': + if (!autocompleteMode) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_list_end', + defaultMessage: 'Expected list closing token.', + })); + } + return this; + case ']': + this.i++; + this.state = ParseState.ParameterSeparator; + break; + case ' ': + case '\t': + this.i++; + break; + case ',': + this.i++; + this.state = ParseState.MultiselectNextValue; + break; + default: + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_character', + defaultMessage: 'Unexpected character.', + })); + } + break; + case ParseState.MultiselectNextValue: + switch (c) { + case ' ': + case '\t': + this.i++; + break; + default: + this.state = ParseState.MultiselectStartValue; + } + break; + default: + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_state', + defaultMessage: 'Unreachable: Unexpected state in matchBinding: `{state}`.', + }, { + state: this.state, + })); } } }; @@ -608,20 +867,20 @@ export class AppCommandParser { private intl: Intl; constructor(store: Store|null, intl: Intl, channelID: string, teamID = '', rootPostID = '') { - this.store = store || getStore() as Store; + this.store = store || getStore(); this.channelID = channelID; this.rootPostID = rootPostID; this.teamID = teamID; this.intl = intl; } - // composeCallFromCommand creates the form submission call - public composeCallFromCommand = async (command: string): Promise<{call: AppCallRequest | null; errorMessage?: string}> => { + // composeCommandSubmitCall creates the form submission call + public composeCommandSubmitCall = async (command: string): Promise<{creq: AppCallRequest | null; errorMessage?: string}> => { let parsed = new ParsedCommand(command, this, this.intl); const commandBindings = this.getCommandBindings(); if (!commandBindings) { - return {call: null, + return {creq: null, errorMessage: this.intl.formatMessage({ id: 'apps.error.parser.no_bindings', defaultMessage: 'No command bindings.', @@ -631,7 +890,7 @@ export class AppCommandParser { parsed = await parsed.matchBinding(commandBindings, false); parsed = parsed.parseForm(false); if (parsed.state === ParseState.Error) { - return {call: null, errorMessage: parserErrorMessage(this.intl, parsed.error, parsed.command, parsed.i)}; + return {creq: null, errorMessage: parserErrorMessage(this.intl, parsed.error, parsed.command, parsed.i)}; } await this.addDefaultAndReadOnlyValues(parsed); @@ -639,7 +898,7 @@ export class AppCommandParser { const missing = this.getMissingFields(parsed); if (missing.length > 0) { const missingStr = missing.map((f) => f.label).join(', '); - return {call: null, + return {creq: null, errorMessage: this.intl.formatMessage({ id: 'apps.error.command.field_missing', defaultMessage: 'Required fields missing: `{fieldName}`.', @@ -648,15 +907,66 @@ export class AppCommandParser { })}; } - return this.composeCallFromParsed(parsed); + const {creq, errorMessage} = await this.composeCallRequest(parsed, parsed.resolvedForm?.submit); + if (errorMessage) { + return {creq: null, errorMessage}; + } + + return {creq}; + }; + + public composeFormFromCommand = async (command: string): Promise<{form: AppForm | null; context: AppContext | null; errorMessage?: string}> => { + let parsed = new ParsedCommand(command, this, this.intl); + + const commandBindings = this.getCommandBindings(); + if (!commandBindings) { + return { + form: null, + context: null, + errorMessage: this.intl.formatMessage({ + id: 'apps.error.parser.no_bindings', + defaultMessage: 'No command bindings.', + })}; + } + + parsed = await parsed.matchBinding(commandBindings, false); + parsed = parsed.parseForm(false); + + const form = JSON.parse(JSON.stringify(parsed.resolvedForm)); + if (!form) { + return { + form: null, + context: null, + errorMessage: this.intl.formatMessage({ + id: 'apps.error.parser.no_form', + defaultMessage: 'No form found.', + }), + }; + } + + const values: AppCallValues = parsed.values; + await this.expandOptions(parsed, values); + + for (const field of form.fields || []) { + if (values[field.name]) { + field.value = values[field.name]; + } + } + + if (!form.title) { + form.title = parsed.binding?.location; + } + + const context = this.getAppContext(parsed.binding!); + return {form, context}; }; private async addDefaultAndReadOnlyValues(parsed: ParsedCommand) { - if (!parsed.form?.fields) { + if (!parsed.resolvedForm?.fields) { return; } - await Promise.all(parsed.form.fields.map(async (f) => { + await Promise.all(parsed.resolvedForm?.fields.map(async (f) => { if (!f.value) { return; } @@ -759,7 +1069,7 @@ export class AppCommandParser { suggestions = this.getCommandSuggestions(parsed); } - if (parsed.form || parsed.incomplete) { + if (parsed.resolvedForm || parsed.incomplete) { parsed = parsed.parseForm(true); if (parsed.state === ParseState.Error) { suggestions = this.getErrorSuggestion(parsed); @@ -776,8 +1086,20 @@ export class AppCommandParser { ParseState.StartParameter, ParseState.ParameterSeparator, ParseState.EndValue, + ParseState.Rest, ]; - const call = parsed.form?.call || parsed.binding?.call || parsed.binding?.form?.call; + + const modalStates: string[] = [ + ParseState.StartParameter, + ParseState.Error, + ParseState.TickValue, + ParseState.QuotedValue, + ParseState.EndValue, + ParseState.Rest, + ParseState.Flag, + ParseState.FlagValueSeparator, + ]; + const call = parsed.resolvedForm?.submit || parsed.binding?.form?.submit; const hasRequired = this.getMissingFields(parsed).length === 0; const hasValue = (parsed.state !== ParseState.EndValue || (parsed.field && parsed.values[parsed.field.name] !== undefined)); @@ -789,6 +1111,14 @@ export class AppCommandParser { } else if (suggestions.length === 0 && (parsed.field?.type !== AppFieldTypes.USER && parsed.field?.type !== AppFieldTypes.CHANNEL)) { suggestions = this.getNoMatchingSuggestion(); } + + if (modalStates.includes(parsed.state) && call && parsed.resolvedForm?.fields?.length) { + const open = getOpenInModalSuggestion(parsed); + if (open) { + suggestions = [...suggestions, open]; + } + } + return suggestions.map((suggestion) => this.decorateSuggestionComplete(parsed, suggestion)); }; @@ -818,111 +1148,254 @@ export class AppCommandParser { }]; }; - // composeCallFromParsed creates the form submission call - private composeCallFromParsed = async (parsed: ParsedCommand): Promise<{call: AppCallRequest | null; errorMessage?: string}> => { + // composeCallRequest creates the form submission call + private composeCallRequest = async (parsed: ParsedCommand, call: AppCall | undefined): Promise<{creq: AppCallRequest | null; errorMessage?: string}> => { if (!parsed.binding) { - return {call: null, + return {creq: null, errorMessage: this.intl.formatMessage({ id: 'apps.error.parser.missing_binding', defaultMessage: 'Missing command bindings.', })}; } - - const call = parsed.form?.call || parsed.binding.call; if (!call) { - return {call: null, + return {creq: null, errorMessage: this.intl.formatMessage({ - id: 'apps.error.parser.missing_call', - defaultMessage: 'Missing binding call.', + id: 'apps.error.parser.missing_submit', + defaultMessage: 'No submit call in binding or form.', })}; } const values: AppCallValues = parsed.values; const {errorMessage} = await this.expandOptions(parsed, values); - if (errorMessage) { - return {call: null, errorMessage}; + return {creq: null, errorMessage}; } const context = this.getAppContext(parsed.binding); - return {call: createCallRequest(call, context, {}, values, parsed.command)}; + return {creq: createCallRequest(call, context, {}, values, parsed.command)}; }; private expandOptions = async (parsed: ParsedCommand, values: AppCallValues): Promise<{errorMessage?: string}> => { - if (!parsed.form?.fields) { + if (!parsed.resolvedForm?.fields) { return {}; } const errors: {[key: string]: string} = {}; - await Promise.all(parsed.form.fields.map(async (f) => { + await Promise.all(parsed.resolvedForm.fields.map(async (f) => { if (!values[f.name]) { return; } switch (f.type) { case AppFieldTypes.DYNAMIC_SELECT: - values[f.name] = {label: '', value: values[f.name]}; + if (f.multiselect && Array.isArray(values[f.name])) { + const options: AppSelectOption[] = []; + const commandValues = values[f.name] as string[]; + for (const value of commandValues) { + if (options.find((o) => o.value === value)) { + errors[f.name] = this.intl.formatMessage({ + id: 'apps.error.command.same_option', + defaultMessage: 'Option repeated for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: value, + }); + } + } + values[f.name] = options; + break; + } + + values[f.name] = {label: values[f.name], value: values[f.name]}; break; case AppFieldTypes.STATIC_SELECT: { - const option = f.options?.find((o) => (o.value === values[f.name])); - if (!option) { + const getOption = (value: string) => { + return f.options?.find((o) => (o.value === value)); + }; + + const setOptionError = (value: string) => { errors[f.name] = this.intl.formatMessage({ id: 'apps.error.command.unknown_option', defaultMessage: 'Unknown option for field `{fieldName}`: `{option}`.', }, { fieldName: f.name, - option: values[f.name], + option: value, }); + values[f.name] = undefined; + }; + + if (f.multiselect && Array.isArray(values[f.name])) { + const options: AppSelectOption[] = []; + const commandValues = values[f.name] as string[]; + for (const value of commandValues) { + const option = getOption(value); + if (!option) { + setOptionError(value); + return; + } + if (options.find((o) => o.value === option.value)) { + errors[f.name] = this.intl.formatMessage({ + id: 'apps.error.command.same_option', + defaultMessage: 'Option repeated for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: value, + }); + } + options.push(option); + } + values[f.name] = options; + break; + } + + const option = getOption(values[f.name]); + if (!option) { + setOptionError(values[f.name]); return; } values[f.name] = option; break; } case AppFieldTypes.USER: { + const getFieldUser = async (userName: string) => { + let user = selectUserByUsername(this.store.getState(), userName); + if (!user) { + const dispatchResult = await this.store.dispatch(getUserByUsername(userName) as any); + if ('error' in dispatchResult) { + return null; + } + user = dispatchResult.data; + } + return user; + }; + + const setUserError = (username: string) => { + errors[f.name] = this.intl.formatMessage({ + id: 'apps.error.command.unknown_user', + defaultMessage: 'Unknown user for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: username, + }); + }; + + if (f.multiselect && Array.isArray(values[f.name])) { + const options: AppSelectOption[] = []; + const commandValues = values[f.name] as string[]; + /* eslint-disable no-await-in-loop */ + for (const value of commandValues) { + let userName = value; + if (userName[0] === '@') { + userName = userName.substr(1); + } + const user = await getFieldUser(userName); + if (!user) { + setUserError(userName); + return; + } + + if (options.find((o) => o.value === user?.id)) { + errors[f.name] = this.intl.formatMessage({ + id: 'apps.error.command.same_user', + defaultMessage: 'User repeated for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: userName, + }); + } + options.push({label: user.username, value: user.id}); + } + /* eslint-enable no-await-in-loop */ + values[f.name] = options; + break; + } + let userName = values[f.name] as string; if (userName[0] === '@') { userName = userName.substr(1); } - let user = selectUserByUsername(this.store.getState(), userName); + const user = await getFieldUser(userName); if (!user) { - const dispatchResult = await this.store.dispatch(getUserByUsername(userName) as any); - if ('error' in dispatchResult) { - errors[f.name] = this.intl.formatMessage({ - id: 'apps.error.command.unknown_user', - defaultMessage: 'Unknown user for field `{fieldName}`: `{option}`.', - }, { - fieldName: f.name, - option: values[f.name], - }); - return; - } - user = dispatchResult.data; + setUserError(userName); + return; } values[f.name] = {label: user.username, value: user.id}; break; } case AppFieldTypes.CHANNEL: { + const getFieldChannel = async (channelName: string) => { + let channel = selectChannelByName(this.store.getState(), channelName); + if (!channel) { + const dispatchResult = await this.store.dispatch(getChannelByNameAndTeamName(getCurrentTeam(this.store.getState()).name, channelName) as any); + if ('error' in dispatchResult) { + return null; + } + channel = dispatchResult.data; + } + return channel; + }; + + const setChannelError = (channelName: string) => { + errors[f.name] = this.intl.formatMessage({ + id: 'apps.error.command.unknown_channel', + defaultMessage: 'Unknown channel for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: channelName, + }); + }; + + if (f.multiselect && Array.isArray(values[f.name])) { + const options: AppSelectOption[] = []; + const commandValues = values[f.name] as string[]; + /* eslint-disable no-await-in-loop */ + for (const value of commandValues) { + let channelName = value; + if (channelName[0] === '~') { + channelName = channelName.substr(1); + } + const channel = await getFieldChannel(channelName); + if (!channel) { + setChannelError(channelName); + return; + } + + if (options.find((o) => o.value === channel?.id)) { + errors[f.name] = this.intl.formatMessage({ + id: 'apps.error.command.same_channel', + defaultMessage: 'Channel repeated for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: channelName, + }); + } + + options.push({label: channel?.display_name, value: channel?.id}); + } + /* eslint-enable no-await-in-loop */ + values[f.name] = options; + break; + } + let channelName = values[f.name] as string; if (channelName[0] === '~') { channelName = channelName.substr(1); } - let channel = selectChannelByName(this.store.getState(), channelName); + const channel = await getFieldChannel(channelName); if (!channel) { - const dispatchResult = await this.store.dispatch(getChannelByNameAndTeamName(getCurrentTeam(this.store.getState()).name, channelName) as any); - if ('error' in dispatchResult) { - errors[f.name] = this.intl.formatMessage({ - id: 'apps.error.command.unknown_channel', - defaultMessage: 'Unknown channel for field `{fieldName}`: `{option}`.', - }, { - fieldName: f.name, - option: values[f.name], - }); - return; - } - channel = dispatchResult.data; + setChannelError(channelName); + return; } values[f.name] = {label: channel?.display_name, value: channel?.id}; break; } + case AppFieldTypes.BOOL: { + const strValue = values[f.name] as string; + if (strValue.toLowerCase() === 'true') { + values[f.name] = true; + } else { + values[f.name] = false; + } + } } })); @@ -939,7 +1412,9 @@ export class AppCommandParser { // decorateSuggestionComplete applies the necessary modifications for a suggestion to be processed private decorateSuggestionComplete = (parsed: ParsedCommand, choice: AutocompleteSuggestion): AutocompleteSuggestion => { - if (choice.Complete && choice.Complete.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID)) { + if (choice.Complete && ( + choice.Complete.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID) || + choice.Complete.endsWith(OPEN_COMMAND_IN_MODAL_ITEM_ID))) { return choice as AutocompleteSuggestion; } @@ -1021,24 +1496,13 @@ export class AppCommandParser { return context; }; - // fetchForm unconditionaly retrieves the form for the given binding (subcommand) - private fetchForm = async (binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => { - if (!binding.call) { - return {error: this.intl.formatMessage({ - id: 'apps.error.parser.missing_call', - defaultMessage: 'Missing binding call.', - })}; - } - - const payload = createCallRequest( - binding.call, - this.getAppContext(binding), - ); - - const res = await this.store.dispatch(doAppCall(payload, AppCallTypes.FORM, this.intl)) as DoAppCallResult; + // fetchSubmittableForm unconditionaly retrieves the form for the given binding (subcommand) + private fetchSubmittableForm = async (source: AppCall, context: AppContext): Promise<{form?: AppForm; error?: string} | undefined> => { + const payload = createCallRequest(source, context); + const res = await this.store.dispatch(doAppFetchForm(payload, this.intl)) as DoAppCallResult; if (res.error) { const errorResponse = res.error; - return {error: errorResponse.error || this.intl.formatMessage({ + return {error: errorResponse.text || this.intl.formatMessage({ id: 'apps.error.unknown', defaultMessage: 'Unknown error.', })}; @@ -1065,18 +1529,32 @@ export class AppCommandParser { })}; } + if (!callResponse.form?.submit) { + return {error: this.intl.formatMessage({ + id: 'apps.error.parser.missing_submit', + defaultMessage: 'No submit call in binding or form.', + })}; + } + return {form: callResponse.form}; }; - public getForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => { + public getSubmittableForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => { const rootID = this.rootPostID || ''; const key = `${this.channelID}-${rootID}-${location}`; - const form = this.rootPostID ? getAppRHSCommandForm(this.store.getState(), key) : getAppCommandForm(this.store.getState(), key); - if (form) { - return {form}; + const submittableForm = this.rootPostID ? getAppRHSCommandForm(this.store.getState(), key) : getAppCommandForm(this.store.getState(), key); + if (submittableForm) { + return {form: submittableForm}; } - const fetched = await this.fetchForm(binding); + if (!binding.form?.source) { + return {error: this.intl.formatMessage({ + id: 'apps.error.parser.missing_source', + defaultMessage: 'Form has neither submit nor source.', + })}; + } + const context = this.getAppContext(binding); + const fetched = await this.fetchSubmittableForm(binding.form.source, context); if (fetched?.form) { let actionType: string = AppsTypes.RECEIVED_APP_COMMAND_FORM; if (this.rootPostID) { @@ -1118,7 +1596,7 @@ export class AppCommandParser { switch (parsed.state) { case ParseState.StartParameter: { // see if there's a matching positional field - const positional = parsed.form?.fields?.find((f: AppField) => f.position === parsed.position + 1); + const positional = parsed.resolvedForm?.fields?.find((f: AppField) => f.position === parsed.position + 1); if (positional) { parsed.field = positional; return this.getValueSuggestions(parsed); @@ -1129,31 +1607,66 @@ export class AppCommandParser { case ParseState.Flag: return this.getFlagNameSuggestions(parsed); + case ParseState.FlagValueSeparator: { + const suggestions = await this.getValueSuggestions(parsed); + if (parsed.field?.multiselect) { + suggestions.unshift({ + Complete: '[', + Suggestion: '[', + Description: 'Start building a list', + Hint: '', + IconData: '', + }); + } + return suggestions; + } case ParseState.EndValue: - case ParseState.FlagValueSeparator: case ParseState.NonspaceValue: + case ParseState.MultiselectNextValue: + case ParseState.MultiselectStart: + case ParseState.MultiselectNonspaceValue: + case ParseState.MultiselectEndValue: + case ParseState.MultiselectStartValue: return this.getValueSuggestions(parsed); case ParseState.EndQuotedValue: case ParseState.QuotedValue: + case ParseState.MultiselectQuotedValue: return this.getValueSuggestions(parsed, '"'); case ParseState.EndTickedValue: case ParseState.TickValue: + case ParseState.MultiselectTickValue: return this.getValueSuggestions(parsed, '`'); + case ParseState.MultiselectValueSeparator: + return this.getMultiselectValueSeparatorSuggestion(); case ParseState.Rest: { - const execute = getExecuteSuggestion(parsed); - const value = await this.getValueSuggestions(parsed); - if (execute) { - return [execute, ...value]; - } - return value; + return this.getValueSuggestions(parsed); } } return []; }; + private getMultiselectValueSeparatorSuggestion = (): AutocompleteSuggestion[] => { + return [ + { + Complete: ',', + Suggestion: ',', + Description: 'Add new element', + Hint: '', + IconData: '', + }, + { + Complete: ']', + Suggestion: ']', + Description: 'End list', + Hint: '', + IconData: '', + }, + ]; + }; + // getMissingFields collects the required fields that were not supplied in a submission private getMissingFields = (parsed: ParsedCommand): AppField[] => { - const form = parsed.form; + const form = parsed.resolvedForm; if (!form) { return []; } @@ -1173,7 +1686,7 @@ export class AppCommandParser { // getFlagNameSuggestions returns suggestions for flag names private getFlagNameSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { - if (!parsed.form || !parsed.form.fields || !parsed.form.fields.length) { + if (!parsed.resolvedForm?.fields?.length) { return []; } @@ -1187,7 +1700,7 @@ export class AppCommandParser { prefix = ''; } - const applicable = parsed.form.fields.filter((field) => field.label && field.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase()) && !parsed.values[field.name]); + const applicable = parsed.resolvedForm.fields.filter((field) => field.label && field.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase()) && !parsed.values[field.name]); if (applicable) { return applicable.map((f) => { return { @@ -1288,8 +1801,8 @@ export class AppCommandParser { })); } - const {call, errorMessage} = await this.composeCallFromParsed(parsed); - if (!call) { + const {creq, errorMessage} = await this.composeCallRequest(parsed, f.lookup); + if (!creq) { return this.makeDynamicSelectSuggestionError(this.intl.formatMessage({ id: 'apps.error.lookup.error_preparing_request', defaultMessage: 'Error preparing lookup request: {errorMessage}', @@ -1297,14 +1810,14 @@ export class AppCommandParser { errorMessage, })); } - call.selected_field = f.name; - call.query = parsed.incomplete; + creq.query = parsed.incomplete; + creq.selected_field = parsed.field?.name; - const res = await this.store.dispatch(doAppCall(call, AppCallTypes.LOOKUP, this.intl)) as DoAppCallResult; + const res = await this.store.dispatch(doAppLookup(creq, this.intl)) as DoAppCallResult; if (res.error) { const errorResponse = res.error; - return this.makeDynamicSelectSuggestionError(errorResponse.error || this.intl.formatMessage({ + return this.makeDynamicSelectSuggestionError(errorResponse.text || this.intl.formatMessage({ id: 'apps.error.unknown', defaultMessage: 'Unknown error.', })); diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts index cb602f03b..3cb74de9e 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts @@ -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; diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_data.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_data.ts index 115459e07..5dc66876c 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_data.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_data.ts @@ -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', diff --git a/app/components/post_draft/draft_input/draft_input.js b/app/components/post_draft/draft_input/draft_input.js index f43888432..aaa5f867e 100644 --- a/app/components/post_draft/draft_input/draft_input.js +++ b/app/components/post_draft/draft_input/draft_input.js @@ -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(''); diff --git a/app/components/post_list/post/body/content/embedded_bindings/button_binding/button_binding.tsx b/app/components/post_list/post/body/content/embedded_bindings/button_binding/button_binding.tsx index 1e119250d..89ac536cf 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/button_binding/button_binding.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/button_binding/button_binding.tsx @@ -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({ diff --git a/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.ts b/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.ts index 985a98a6d..465104bce 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.ts +++ b/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.ts @@ -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, }; diff --git a/app/components/post_list/post/body/content/embedded_bindings/embedded_binding.tsx b/app/components/post_list/post/body/content/embedded_bindings/embedded_binding.tsx index a67936405..410ce0c26 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/embedded_binding.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/embedded_binding.tsx @@ -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([]); - 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) && diff --git a/app/components/post_list/post/body/content/embedded_bindings/embedded_sub_bindings.tsx b/app/components/post_list/post/body/content/embedded_bindings/embedded_sub_bindings.tsx index 77a6c1fec..2c1345f59 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/embedded_sub_bindings.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/embedded_sub_bindings.tsx @@ -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; } diff --git a/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.ts b/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.ts index 079f68921..e6558a0af 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.ts +++ b/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.ts @@ -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, }; diff --git a/app/components/post_list/post/body/content/embedded_bindings/menu_binding/menu_binding.tsx b/app/components/post_list/post/body/content/embedded_bindings/menu_binding/menu_binding.tsx index 66eed3fd0..924cfa221 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/menu_binding/menu_binding.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/menu_binding/menu_binding.tsx @@ -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(); 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({ diff --git a/app/components/post_list/post/body/content/index.tsx b/app/components/post_list/post/body/content/index.tsx index 5cc8b9453..c2bb31252 100644 --- a/app/components/post_list/post/body/content/index.tsx +++ b/app/components/post_list/post/body/content/index.tsx @@ -29,7 +29,7 @@ const contentType: Record = { }; 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; } diff --git a/app/mm-redux/actions/users.ts b/app/mm-redux/actions/users.ts index 8372bca57..088200342 100644 --- a/app/mm-redux/actions/users.ts +++ b/app/mm-redux/actions/users.ts @@ -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; diff --git a/app/mm-redux/constants/apps.ts b/app/mm-redux/constants/apps.ts index 8ac4d19ee..1852949be 100644 --- a/app/mm-redux/constants/apps.ts +++ b/app/mm-redux/constants/apps.ts @@ -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', diff --git a/app/mm-redux/reducers/entities/__snapshots__/apps.test.js.snap b/app/mm-redux/reducers/entities/__snapshots__/apps.test.js.snap index cb20632f1..1934e72b7 100644 --- a/app/mm-redux/reducers/entities/__snapshots__/apps.test.js.snap +++ b/app/mm-redux/reducers/entities/__snapshots__/apps.test.js.snap @@ -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", }, diff --git a/app/mm-redux/reducers/entities/apps.test.js b/app/mm-redux/reducers/entities/apps.test.js index e1d99719e..e243c955f 100644 --- a/app/mm-redux/reducers/entities/apps.test.js +++ b/app/mm-redux/reducers/entities/apps.test.js @@ -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, }, ], }, diff --git a/app/mm-redux/types/apps.ts b/app/mm-redux/types/apps.ts index 876ae339d..9cb504567 100644 --- a/app/mm-redux/types/apps.ts +++ b/app/mm-redux/types/apps.ts @@ -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 = { 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; diff --git a/app/screens/apps_form/apps_form_component.test.tsx b/app/screens/apps_form/apps_form_component.test.tsx index 98200f34c..25f91e3bf 100644 --- a/app/screens/apps_form/apps_form_component.test.tsx +++ b/app/screens/apps_form/apps_form_component.test.tsx @@ -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', diff --git a/app/screens/apps_form/apps_form_component.tsx b/app/screens/apps_form/apps_form_component.tsx index 640d3e564..e69265158 100644 --- a/app/screens/apps_form/apps_form_component.tsx +++ b/app/screens/apps_form/apps_form_component.tsx @@ -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 { 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 { performLookup = async (name: string, userInput: string): Promise => { 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 { 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 { }; 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 { 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); diff --git a/app/screens/apps_form/apps_form_container.tsx b/app/screens/apps_form/apps_form_container.tsx index ced50811a..68641d1d5 100644 --- a/app/screens/apps_form/apps_form_container.tsx +++ b/app/screens/apps_form/apps_form_container.tsx @@ -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; + doAppSubmit: DoAppSubmit; + doAppFetchForm: DoAppFetchForm; + doAppLookup: DoAppLookup; postEphemeralCallResponseForContext: PostEphemeralCallResponseForContext; handleGotoLocation: (href: string, intl: any) => Promise; }; @@ -61,20 +63,21 @@ export default class AppsFormContainer extends PureComponent { )))}; } - 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; if (res.error) { return res; @@ -83,8 +86,8 @@ export default class AppsFormContainer extends PureComponent { 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 { })))}; } - 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 { })))}; } - 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 { }, {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 ( ; + doAppFetchForm: DoAppFetchForm; + doAppLookup: DoAppLookup; postEphemeralCallResponseForContext: PostEphemeralCallResponseForContext; }; @@ -27,7 +29,9 @@ function mapStateToProps(state: GlobalState) { function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators, Actions>({ - doAppCall, + doAppSubmit, + doAppFetchForm, + doAppLookup, postEphemeralCallResponseForContext, handleGotoLocation, }, dispatch), diff --git a/app/screens/channel_info/bindings/bindings.tsx b/app/screens/channel_info/bindings/bindings.tsx index befcf5ab2..3d206a4aa 100644 --- a/app/screens/channel_info/bindings/bindings.tsx +++ b/app/screens/channel_info/bindings/bindings.tsx @@ -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; }; @@ -70,7 +70,7 @@ type OptionProps = { intl: typeof intlShape; currentTeamId: string; actions: { - doAppCall: DoAppCall; + handleBindingClick: HandleBindingClick; postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel; handleGotoLocation: (href: string, intl: any) => Promise; }; @@ -87,38 +87,22 @@ class Option extends React.PureComponent { 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 { 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 { 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 { return; case AppCallResponseTypes.FORM: await dismissModal(); - showAppForm(callResp.form, call, theme); + showAppForm(callResp.form, context, theme); return; default: { const title = intl.formatMessage({ diff --git a/app/screens/channel_info/bindings/index.ts b/app/screens/channel_info/bindings/index.ts index 3b2358244..2154f1e79 100644 --- a/app/screens/channel_info/bindings/index.ts +++ b/app/screens/channel_info/bindings/index.ts @@ -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) { return { actions: bindActionCreators, Actions>({ - doAppCall, + handleBindingClick, postEphemeralCallResponseForChannel, handleGotoLocation, }, dispatch), diff --git a/app/screens/post_options/bindings/bindings.tsx b/app/screens/post_options/bindings/bindings.tsx index 5b135f3c7..4bfbf7ba2 100644 --- a/app/screens/post_options/bindings/bindings.tsx +++ b/app/screens/post_options/bindings/bindings.tsx @@ -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; }; @@ -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; }; @@ -110,13 +110,7 @@ type OptionProps = { class Option extends React.PureComponent { 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 { 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 { 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 { 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({ diff --git a/app/screens/post_options/bindings/index.ts b/app/screens/post_options/bindings/index.ts index 4a1e5269c..e3006bd10 100644 --- a/app/screens/post_options/bindings/index.ts +++ b/app/screens/post_options/bindings/index.ts @@ -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) { return { actions: bindActionCreators, Actions>({ - doAppCall, + handleBindingClick, postEphemeralCallResponseForPost, handleGotoLocation, }, dispatch), diff --git a/app/utils/apps.test.ts b/app/utils/apps.test.ts index aef81a952..981b7c79d 100644 --- a/app/utils/apps.test.ts +++ b/app/utils/apps.test.ts @@ -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', + }, }, }, ], diff --git a/app/utils/apps.ts b/app/utils/apps.ts index c53e9832f..b123d59f8 100644 --- a/app/utils/apps.ts +++ b/app/utils/apps.ts @@ -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 => { return { type: AppCallResponseTypes.ERROR, - error: errMessage, + text: errMessage, }; }; diff --git a/app/utils/mentions/index.ts b/app/utils/mentions/index.ts index 249da4677..bee1614a7 100644 --- a/app/utils/mentions/index.ts +++ b/app/utils/mentions/index.ts @@ -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; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 561994d48..f5b9bf196 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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}.", diff --git a/types/actions/apps.d.ts b/types/actions/apps.d.ts index 97b5c8f19..e4facd164 100644 --- a/types/actions/apps.d.ts +++ b/types/actions/apps.d.ts @@ -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 = { @@ -9,10 +9,6 @@ export type DoAppCallResult = { error?: AppCallResponse; } -export interface DoAppCall { - (call: AppCallRequest, type: AppCallType, intl: any): Promise>; -} - 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 { + (binding: AppBinding, context: AppContext, intl: any): Promise>; +} + +export interface DoAppSubmit { + (call: AppCallRequest, intl: any): Promise>; +} + +export interface DoAppFetchForm { + (call: AppCallRequest, intl: any): Promise>; +} + +export interface DoAppLookup { + (call: AppCallRequest, intl: any): Promise>; +}