From 11c8454ee274b53aee16553fffbe4eed05be2d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Mon, 22 Mar 2021 23:02:06 +0100 Subject: [PATCH] Feature - Cloud Apps (#5226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Daniel Espino García Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com> Co-authored-by: Ben Schumacher --- app/actions/apps.ts | 116 ++ app/actions/views/channel.js | 7 + app/actions/views/command.js | 39 - app/actions/views/command.ts | 95 ++ app/actions/views/post.js | 22 + app/actions/websocket/apps.ts | 18 + app/actions/websocket/index.ts | 4 + .../slash_suggestion.test.tsx.snap | 60 + .../app_command_parser.test.ts | 958 +++++++++++++ .../app_command_parser/app_command_parser.ts | 1247 +++++++++++++++++ .../app_command_parser_dependencies.ts | 78 ++ .../tests/app_command_parser_test_data.ts | 228 +++ .../app_command_parser_test_dependencies.ts | 14 + .../slash_suggestion/{index.js => index.ts} | 10 +- .../slash_suggestion.test.tsx | 263 ++++ ...ash_suggestion.js => slash_suggestion.tsx} | 145 +- ...tion_item.js => slash_suggestion_item.tsx} | 37 +- .../button_binding/button_binding.tsx | 149 ++ .../button_binding_text.test.tsx | 143 ++ .../button_binding/button_binding_text.tsx | 85 ++ .../embedded_bindings/button_binding/index.ts | 48 + .../embedded_bindings/embed_text.tsx | 115 ++ .../embedded_bindings/embed_title.tsx | 69 + .../embedded_bindings/embedded_binding.tsx | 85 ++ .../embedded_sub_bindings.tsx | 52 + app/components/embedded_bindings/index.tsx | 57 + .../embedded_bindings/menu_binding/index.ts | 45 + .../menu_binding/menu_binding.tsx | 137 ++ .../action_button/action_button_text.js | 11 +- app/components/post_body/index.js | 2 + app/components/post_body/post_body.js | 4 +- app/components/post_body/post_body.test.js | 1 + .../post_body_additional_content/index.js | 4 +- .../post_body_additional_content.js | 49 +- .../post_body_additional_content.test.js | 1 + .../post_draft/draft_input/draft_input.js | 2 +- .../widgets/settings/text_setting.js | 2 + app/constants/emoji.js | 10 + app/constants/screen.js | 3 + app/constants/websocket.ts | 1 + app/mm-redux/action_types/apps.ts | 7 + app/mm-redux/action_types/index.ts | 2 + app/mm-redux/actions/apps.ts | 15 + app/mm-redux/actions/helpers.ts | 2 +- app/mm-redux/actions/integrations.ts | 8 +- app/mm-redux/client/client4.ts | 30 + app/mm-redux/constants/apps.ts | 46 + .../entities/__snapshots__/apps.test.js.snap | 340 +++++ app/mm-redux/reducers/entities/apps.test.js | 360 +++++ app/mm-redux/reducers/entities/apps.ts | 23 + app/mm-redux/reducers/entities/index.ts | 2 + app/mm-redux/selectors/entities/apps.ts | 16 + app/mm-redux/types/apps.ts | 205 +++ app/mm-redux/types/integration_actions.ts | 30 + app/mm-redux/types/integrations.ts | 21 + app/mm-redux/types/store.ts | 2 + .../app_selector_screen.test.tsx.snap | 626 +++++++++ .../app_selector_screen.test.tsx | 127 ++ .../app_selector_screen.tsx | 465 ++++++ app/screens/app_selector_screen/index.ts | 40 + .../dialog_introduction_text.test.tsx.snap | 126 ++ .../app_form_selector/app_form_selector.tsx | 280 ++++ .../apps_form/app_form_selector/index.ts | 18 + app/screens/apps_form/apps_form_component.tsx | 390 ++++++ app/screens/apps_form/apps_form_container.tsx | 241 ++++ app/screens/apps_form/apps_form_field.tsx | 150 ++ .../dialog_introduction_text.test.tsx | 38 + .../apps_form/dialog_introduction_text.tsx | 58 + app/screens/apps_form/index.ts | 37 + .../channel_post_list.test.js.snap | 1 + .../channel_post_list/channel_post_list.js | 2 + .../__snapshots__/channel_info.test.js.snap | 93 ++ .../channel_info/bindings/bindings.tsx | 147 ++ app/screens/channel_info/bindings/index.ts | 48 + app/screens/channel_info/channel_info.js | 4 + app/screens/channel_info/channel_info_row.js | 14 +- app/screens/index.js | 6 + app/screens/permalink/permalink.js | 2 + .../__snapshots__/post_options.test.js.snap | 48 +- .../post_options/bindings/bindings.tsx | 159 +++ app/screens/post_options/bindings/index.ts | 58 + app/screens/post_options/index.js | 8 +- app/screens/post_options/post_option.js | 44 +- app/screens/post_options/post_options.js | 18 + app/screens/post_options/post_options.test.js | 1 + .../search_result_post.test.js.snap | 1 + .../search_result_post/search_result_post.js | 2 + app/utils/apps.test.ts | 443 ++++++ app/utils/apps.ts | 181 +++ assets/base/i18n/en.json | 36 + types/modules/react-native-button.d.ts | 3 + 91 files changed, 9303 insertions(+), 137 deletions(-) create mode 100644 app/actions/apps.ts delete mode 100644 app/actions/views/command.js create mode 100644 app/actions/views/command.ts create mode 100644 app/actions/websocket/apps.ts create mode 100644 app/components/autocomplete/slash_suggestion/__snapshots__/slash_suggestion.test.tsx.snap create mode 100644 app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts create mode 100644 app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts create mode 100644 app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts create mode 100644 app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_data.ts create mode 100644 app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_dependencies.ts rename app/components/autocomplete/slash_suggestion/{index.js => index.ts} (85%) create mode 100644 app/components/autocomplete/slash_suggestion/slash_suggestion.test.tsx rename app/components/autocomplete/slash_suggestion/{slash_suggestion.js => slash_suggestion.tsx} (55%) rename app/components/autocomplete/slash_suggestion/{slash_suggestion_item.js => slash_suggestion_item.tsx} (83%) create mode 100644 app/components/embedded_bindings/button_binding/button_binding.tsx create mode 100644 app/components/embedded_bindings/button_binding/button_binding_text.test.tsx create mode 100644 app/components/embedded_bindings/button_binding/button_binding_text.tsx create mode 100644 app/components/embedded_bindings/button_binding/index.ts create mode 100644 app/components/embedded_bindings/embed_text.tsx create mode 100644 app/components/embedded_bindings/embed_title.tsx create mode 100644 app/components/embedded_bindings/embedded_binding.tsx create mode 100644 app/components/embedded_bindings/embedded_sub_bindings.tsx create mode 100644 app/components/embedded_bindings/index.tsx create mode 100644 app/components/embedded_bindings/menu_binding/index.ts create mode 100644 app/components/embedded_bindings/menu_binding/menu_binding.tsx create mode 100644 app/mm-redux/action_types/apps.ts create mode 100644 app/mm-redux/actions/apps.ts create mode 100644 app/mm-redux/constants/apps.ts create mode 100644 app/mm-redux/reducers/entities/__snapshots__/apps.test.js.snap create mode 100644 app/mm-redux/reducers/entities/apps.test.js create mode 100644 app/mm-redux/reducers/entities/apps.ts create mode 100644 app/mm-redux/selectors/entities/apps.ts create mode 100644 app/mm-redux/types/apps.ts create mode 100644 app/mm-redux/types/integration_actions.ts create mode 100644 app/screens/app_selector_screen/__snapshots__/app_selector_screen.test.tsx.snap create mode 100644 app/screens/app_selector_screen/app_selector_screen.test.tsx create mode 100644 app/screens/app_selector_screen/app_selector_screen.tsx create mode 100644 app/screens/app_selector_screen/index.ts create mode 100644 app/screens/apps_form/__snapshots__/dialog_introduction_text.test.tsx.snap create mode 100644 app/screens/apps_form/app_form_selector/app_form_selector.tsx create mode 100644 app/screens/apps_form/app_form_selector/index.ts create mode 100644 app/screens/apps_form/apps_form_component.tsx create mode 100644 app/screens/apps_form/apps_form_container.tsx create mode 100644 app/screens/apps_form/apps_form_field.tsx create mode 100644 app/screens/apps_form/dialog_introduction_text.test.tsx create mode 100644 app/screens/apps_form/dialog_introduction_text.tsx create mode 100644 app/screens/apps_form/index.ts create mode 100644 app/screens/channel_info/bindings/bindings.tsx create mode 100644 app/screens/channel_info/bindings/index.ts create mode 100644 app/screens/post_options/bindings/bindings.tsx create mode 100644 app/screens/post_options/bindings/index.ts create mode 100644 app/utils/apps.test.ts create mode 100644 app/utils/apps.ts create mode 100644 types/modules/react-native-button.d.ts diff --git a/app/actions/apps.ts b/app/actions/apps.ts new file mode 100644 index 000000000..c654382ad --- /dev/null +++ b/app/actions/apps.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Client4} from '@mm-redux/client'; +import {ActionFunc} from '@mm-redux/types/actions'; +import {AppCallResponse, AppForm, AppCallRequest, AppCallType} from '@mm-redux/types/apps'; +import {AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps'; +import {handleGotoLocation} from '@mm-redux/actions/integrations'; +import {showModal} from './navigation'; +import {Theme} from '@mm-redux/types/preferences'; +import CompassIcon from '@components/compass_icon'; +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import EphemeralStore from '@store/ephemeral_store'; +import {makeCallErrorResponse} from '@utils/apps'; + +export function doAppCall(call: AppCallRequest, type: AppCallType, intl: any): ActionFunc { + return async (dispatch, getState) => { + try { + const res = await Client4.executeAppCall(call, type) as AppCallResponse; + const responseType = res.type || AppCallResponseTypes.OK; + + switch (responseType) { + case AppCallResponseTypes.OK: + return {data: res}; + case AppCallResponseTypes.ERROR: + return {error: res}; + case AppCallResponseTypes.FORM: { + if (!res.form) { + const errMsg = intl.formatMessage({ + id: 'apps.error.responses.form.no_form', + defaultMessage: 'Response type is `form`, but no form was included in response.', + }); + return {error: makeCallErrorResponse(errMsg)}; + } + + const screen = EphemeralStore.getNavigationTopComponentId(); + if (type === AppCallTypes.SUBMIT && screen !== 'AppForm') { + showAppForm(res.form, call, getTheme(getState())); + } + + return {data: res}; + } + case AppCallResponseTypes.NAVIGATE: + if (!res.navigate_to_url) { + const errMsg = intl.formatMessage({ + id: 'apps.error.responses.navigate.no_url', + defaultMessage: 'Response type is `navigate`, but no url was included in response.', + }); + return {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)}; + } + + dispatch(handleGotoLocation(res.navigate_to_url, intl)); + + 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) { + const errMsg = error.message || intl.formatMessage({ + id: 'apps.error.responses.unexpected_error', + defaultMessage: 'Received an unexpected error.', + }); + return {error: makeCallErrorResponse(errMsg)}; + } + }; +} + +const showAppForm = async (form: AppForm, call: AppCallRequest, theme: Theme) => { + const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor); + + let submitButtons = [{ + id: 'submit-form', + showAsAction: 'always', + text: 'Submit', + }]; + if (form.submit_buttons) { + const options = form.fields.find((f) => f.name === form.submit_buttons)?.options; + const newButtons = options?.map((o) => { + return { + id: 'submit-form_' + o.value, + showAsAction: 'always', + text: o.label, + }; + }); + if (newButtons && newButtons.length > 0) { + submitButtons = newButtons; + } + } + const options = { + topBar: { + leftButtons: [{ + id: 'close-dialog', + icon: closeButton, + }], + rightButtons: submitButtons, + }, + }; + + const passProps = {form, call}; + showModal('AppForm', form.title, passProps, options); +}; diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index 02a4f2133..1b603a4c6 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -39,6 +39,8 @@ import {getChannelReachable} from '@selectors/channel'; import telemetry from '@telemetry'; import {isDirectChannelVisible, isGroupChannelVisible, getChannelSinceValue, privateChannelJoinPrompt} from '@utils/channels'; import {isPendingPost} from '@utils/general'; +import {fetchAppBindings} from '@mm-redux/actions/apps'; +import {appsEnabled} from '@utils/apps'; const MAX_RETRIES = 3; @@ -185,6 +187,7 @@ export function handleSelectChannel(channelId) { return async (dispatch, getState) => { const dt = Date.now(); const state = getState(); + const {currentUserId} = state.entities.users; const {channels, currentChannelId, myMembers} = state.entities.channels; const {currentTeamId} = state.entities.teams; const channel = channels[channelId]; @@ -211,6 +214,10 @@ export function handleSelectChannel(channelId) { dispatch(batchActions(actions, 'BATCH_SWITCH_CHANNEL')); + if (appsEnabled(state)) { + //TODO improve sync method + dispatch(fetchAppBindings(currentUserId, channelId)); + } console.log('channel switch to', channel?.display_name, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line } diff --git a/app/actions/views/command.js b/app/actions/views/command.js deleted file mode 100644 index 35af82afa..000000000 --- a/app/actions/views/command.js +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {IntegrationTypes} from '@mm-redux/action_types'; -import {executeCommand as executeCommandService} from '@mm-redux/actions/integrations'; -import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; - -export function executeCommand(message, channelId, rootId) { - return async (dispatch, getState) => { - const state = getState(); - - const teamId = getCurrentTeamId(state); - - const args = { - channel_id: channelId, - team_id: teamId, - root_id: rootId, - parent_id: rootId, - }; - - let msg = message; - - let cmdLength = msg.indexOf(' '); - if (cmdLength < 0) { - cmdLength = msg.length; - } - - const cmd = msg.substring(0, cmdLength).toLowerCase(); - msg = cmd + msg.substring(cmdLength, msg.length); - - const {data, error} = await dispatch(executeCommandService(msg, args)); - - if (data?.trigger_id) { //eslint-disable-line camelcase - dispatch({type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, data: data.trigger_id}); - } - - return {data, error}; - }; -} diff --git a/app/actions/views/command.ts b/app/actions/views/command.ts new file mode 100644 index 000000000..a6c57892e --- /dev/null +++ b/app/actions/views/command.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {intlShape} from 'react-intl'; + +import {IntegrationTypes} from '@mm-redux/action_types'; +import {executeCommand as executeCommandService} from '@mm-redux/actions/integrations'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps'; +import {DispatchFunc, GetStateFunc, ActionFunc} from '@mm-redux/types/actions'; + +import {AppCommandParser} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser'; + +import {doAppCall} from '@actions/apps'; +import {appsEnabled} from '@utils/apps'; +import {AppCallResponse} from '@mm-redux/types/apps'; +import {sendEphemeralPost} from './post'; + +export function executeCommand(message: string, channelId: string, rootId: string, intl: typeof intlShape): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + + const teamId = getCurrentTeamId(state); + + const args = { + channel_id: channelId, + team_id: teamId, + root_id: rootId, + parent_id: rootId, + }; + + let msg = message; + + let cmdLength = msg.indexOf(' '); + if (cmdLength < 0) { + cmdLength = msg.length; + } + + const cmd = msg.substring(0, cmdLength).toLowerCase(); + msg = cmd + msg.substring(cmdLength, msg.length); + + const appsAreEnabled = appsEnabled(state); + if (appsAreEnabled) { + const parser = new AppCommandParser({dispatch, getState}, intl, args.channel_id, args.root_id); + if (parser.isAppCommand(msg)) { + const call = await parser.composeCallFromCommand(message); + const createErrorMessage = (errMessage: string) => { + return {error: {message: errMessage}}; + }; + + if (!call) { + return createErrorMessage(intl.formatMessage({ + id: 'mobile.commands.error_title', + defaultMessage: 'Error Executing Command', + })); + } + + const res = await dispatch(doAppCall(call, AppCallTypes.SUBMIT, intl)); + if (res.error) { + const errorResponse = res.error as AppCallResponse; + return createErrorMessage(errorResponse.error || intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error.', + })); + } + const callResp = res.data as AppCallResponse; + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.markdown) { + dispatch(sendEphemeralPost(callResp.markdown, args.channel_id, args.parent_id)); + } + return {data: {}}; + case AppCallResponseTypes.FORM: + case AppCallResponseTypes.NAVIGATE: + return {data: {}}; + default: + return createErrorMessage(intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResp.type, + })); + } + } + } + + const {data, error} = await dispatch(executeCommandService(msg, args)); + + if (data?.trigger_id) { //eslint-disable-line camelcase + dispatch({type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, data: data.trigger_id}); + } + + return {data, error}; + }; +} diff --git a/app/actions/views/post.js b/app/actions/views/post.js index a01451f2b..f2d08a651 100644 --- a/app/actions/views/post.js +++ b/app/actions/views/post.js @@ -28,6 +28,28 @@ import {getChannelSinceValue} from '@utils/channels'; import {getEmojisInPosts} from './emoji'; +export function sendEphemeralPost(message, channelId = '', parentId = '') { + return async (dispatch, getState) => { + const timestamp = Date.now(); + const post = { + id: generateId(), + user_id: '0', + channel_id: channelId || getCurrentChannelId(getState()), + message, + type: Posts.POST_TYPES.EPHEMERAL, + create_at: timestamp, + update_at: timestamp, + root_id: parentId, + parent_id: parentId, + props: {}, + }; + + dispatch(receivedNewPost(post)); + + return {}; + }; +} + export function sendAddToChannelEphemeralPost(user, addedUsername, message, channelId, postRootId = '') { return async (dispatch) => { const timestamp = Date.now(); diff --git a/app/actions/websocket/apps.ts b/app/actions/websocket/apps.ts new file mode 100644 index 000000000..3fbce855f --- /dev/null +++ b/app/actions/websocket/apps.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fetchAppBindings} from '@mm-redux/actions/apps'; +import {getCurrentChannelId} from '@mm-redux/selectors/entities/common'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; +import {ActionResult, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; +import {appsEnabled} from '@utils/apps'; + +export function handleRefreshAppsBindings() { + return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => { + const state = getState(); + if (appsEnabled(state)) { + dispatch(fetchAppBindings(getCurrentUserId(state), getCurrentChannelId(state))); + } + return {data: true}; + }; +} diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 8de753ecf..d22785178 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -46,6 +46,7 @@ import {handleLeaveTeamEvent, handleUpdateTeamEvent, handleTeamAddedEvent} from import {handleStatusChangedEvent, handleUserAddedEvent, handleUserRemovedEvent, handleUserRoleUpdated, handleUserUpdatedEvent} from './users'; import {getChannelSinceValue} from '@utils/channels'; import {getPostIdsInChannel} from '@mm-redux/selectors/entities/posts'; +import {handleRefreshAppsBindings} from './apps'; export function init(additionalOptions: any = {}) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { @@ -377,6 +378,9 @@ function handleEvent(msg: WebSocketMessage) { return dispatch(handleOpenDialogEvent(msg)); case WebsocketEvents.RECEIVED_GROUP: return dispatch(handleGroupUpdatedEvent(msg)); + case WebsocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: { + return dispatch(handleRefreshAppsBindings()); + } } return {data: true}; diff --git a/app/components/autocomplete/slash_suggestion/__snapshots__/slash_suggestion.test.tsx.snap b/app/components/autocomplete/slash_suggestion/__snapshots__/slash_suggestion.test.tsx.snap new file mode 100644 index 000000000..adbb00233 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/__snapshots__/slash_suggestion.test.tsx.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/autocomplete/slash_suggestion should match snapshot 1`] = ` + +`; 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 new file mode 100644 index 000000000..b2802ea37 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts @@ -0,0 +1,958 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable max-lines */ + +import { + thunk, + configureStore, + Client4, + AppBinding, + checkForExecuteSuggestion, +} from './tests/app_command_parser_test_dependencies'; + +import { + AppCallResponseTypes, + AppCallTypes, + AutocompleteSuggestion, +} from './app_command_parser_dependencies'; + +import { + AppCommandParser, + ParseState, + ParsedCommand, +} from './app_command_parser'; + +import { + reduxTestState, + testBindings, +} from './tests/app_command_parser_test_data'; + +const mockStore = configureStore([thunk]); + +describe('AppCommandParser', () => { + const makeStore = async (bindings: AppBinding[]) => { + const initialState = { + ...reduxTestState, + entities: { + ...reduxTestState.entities, + apps: {bindings}, + }, + } as any; + const testStore = await mockStore(initialState); + + return testStore; + }; + + const intl = { + formatMessage: (message: {id: string, defaultMessage: string}) => { + return message.defaultMessage; + }, + }; + + let parser: AppCommandParser; + beforeEach(async () => { + const store = await makeStore(testBindings); + parser = new AppCommandParser(store as any, intl, 'current_channel_id', 'root_id'); + }); + + type Variant = { + expectError?: string; + verify?(parsed: ParsedCommand): void; + } + + type TC = { + title: string; + command: string; + submit: Variant; + autocomplete?: Variant; // if undefined, use same checks as submnit + } + + const checkResult = (parsed: ParsedCommand, v: Variant) => { + if (v.expectError) { + expect(parsed.state).toBe(ParseState.Error); + expect(parsed.error).toBe(v.expectError); + } else { + // expect(parsed).toBe(1); + expect(parsed.error).toBe(''); + expect(v.verify).toBeTruthy(); + if (v.verify) { + v.verify(parsed); + } + } + }; + + describe('getSuggestionsBase', () => { + test('string matches 1', () => { + const res = parser.getSuggestionsBase('/'); + expect(res).toHaveLength(2); + }); + + test('string matches 2', () => { + const res = parser.getSuggestionsBase('/ji'); + expect(res).toHaveLength(1); + }); + + test('string matches 3', () => { + const res = parser.getSuggestionsBase('/jira'); + expect(res).toHaveLength(1); + }); + + test('string matches case insensitive', () => { + const res = parser.getSuggestionsBase('/JiRa'); + expect(res).toHaveLength(1); + }); + + test('string is past base command', () => { + const res = parser.getSuggestionsBase('/jira '); + expect(res).toHaveLength(0); + }); + + test('other command matches', () => { + const res = parser.getSuggestionsBase('/other'); + expect(res).toHaveLength(1); + }); + + test('string does not match', () => { + const res = parser.getSuggestionsBase('/wrong'); + expect(res).toHaveLength(0); + }); + }); + + describe('matchBinding', () => { + const table: TC[] = [ + { + title: 'full command', + command: '/jira issue create --project P --summary = "SUM MA RY" --verbose --epic=epic2', + submit: {verify: (parsed: ParsedCommand): void => { + expect(parsed.state).toBe(ParseState.EndCommand); + expect(parsed.binding?.label).toBe('create'); + expect(parsed.incomplete).toBe('--project'); + expect(parsed.incompleteStart).toBe(19); + }}, + }, + { + title: 'full command case insensitive', + command: '/JiRa IsSuE CrEaTe --PrOjEcT P --SuMmArY = "SUM MA RY" --VeRbOsE --EpIc=epic2', + submit: {verify: (parsed: ParsedCommand): void => { + expect(parsed.state).toBe(ParseState.EndCommand); + expect(parsed.binding?.label).toBe('create'); + expect(parsed.incomplete).toBe('--PrOjEcT'); + expect(parsed.incompleteStart).toBe(19); + }}, + }, + { + title: 'incomplete top command', + command: '/jir', + autocomplete: {expectError: '`{command}`: No matching command found in this workspace.'}, + submit: {expectError: '`{command}`: No matching command found in this workspace.'}, + }, + { + title: 'no space after the top command', + command: '/jira', + autocomplete: {expectError: '`{command}`: No matching command found in this workspace.'}, + submit: {verify: (parsed: ParsedCommand): void => { + expect(parsed.state).toBe(ParseState.Command); + expect(parsed.binding?.label).toBe('jira'); + }}, + }, + { + title: 'space after the top command', + command: '/jira ', + submit: {verify: (parsed: ParsedCommand): void => { + expect(parsed.state).toBe(ParseState.Command); + expect(parsed.binding?.label).toBe('jira'); + }}, + }, + { + title: 'middle of subcommand', + command: '/jira iss', + autocomplete: {verify: (parsed: ParsedCommand): void => { + expect(parsed.state).toBe(ParseState.Command); + expect(parsed.binding?.label).toBe('jira'); + expect(parsed.incomplete).toBe('iss'); + expect(parsed.incompleteStart).toBe(9); + }}, + submit: {verify: (parsed: ParsedCommand): void => { + expect(parsed.state).toBe(ParseState.EndCommand); + expect(parsed.binding?.label).toBe('jira'); + expect(parsed.incomplete).toBe('iss'); + expect(parsed.incompleteStart).toBe(9); + }}, + }, + { + title: 'second subcommand, no space', + command: '/jira issue', + autocomplete: {verify: (parsed: ParsedCommand): void => { + expect(parsed.state).toBe(ParseState.Command); + expect(parsed.binding?.label).toBe('jira'); + expect(parsed.incomplete).toBe('issue'); + expect(parsed.incompleteStart).toBe(6); + }}, + submit: {verify: (parsed: ParsedCommand): void => { + expect(parsed.state).toBe(ParseState.Command); + expect(parsed.binding?.label).toBe('issue'); + expect(parsed.location).toBe('/jira/issue'); + }}, + }, + { + title: 'token after the end of bindings, no space', + command: '/jira issue create something', + autocomplete: {verify: (parsed: ParsedCommand): void => { + expect(parsed.state).toBe(ParseState.Command); + expect(parsed.binding?.label).toBe('create'); + expect(parsed.incomplete).toBe('something'); + expect(parsed.incompleteStart).toBe(20); + }}, + submit: {verify: (parsed: ParsedCommand): void => { + expect(parsed.state).toBe(ParseState.EndCommand); + expect(parsed.binding?.label).toBe('create'); + expect(parsed.incomplete).toBe('something'); + expect(parsed.incompleteStart).toBe(20); + }}, + }, + { + title: 'token after the end of bindings, with space', + command: '/jira issue create something ', + submit: {verify: (parsed: ParsedCommand): void => { + expect(parsed.state).toBe(ParseState.EndCommand); + expect(parsed.binding?.label).toBe('create'); + expect(parsed.incomplete).toBe('something'); + expect(parsed.incompleteStart).toBe(20); + }}, + }, + ]; + + table.forEach((tc) => { + test(tc.title, async () => { + const bindings = testBindings[0].bindings as AppBinding[]; + + let a = new ParsedCommand(tc.command, parser, intl); + a = await a.matchBinding(bindings, true); + checkResult(a, tc.autocomplete || tc.submit); + + let s = new ParsedCommand(tc.command, parser, intl); + s = await s.matchBinding(bindings, false); + checkResult(s, tc.submit); + }); + }); + }); + + describe('parseForm', () => { + const table: TC[] = [ + { + title: 'happy full create', + command: '/jira issue create --project `P 1` --summary "SUM MA RY" --verbose --epic=epic2', + 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.incomplete).toBe('epic2'); + expect(parsed.incompleteStart).toBe(75); + expect(parsed.values?.project).toBe('P 1'); + expect(parsed.values?.epic).toBeUndefined(); + expect(parsed.values?.summary).toBe('SUM MA RY'); + expect(parsed.values?.verbose).toBe('true'); + }}, + 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.values?.project).toBe('P 1'); + expect(parsed.values?.epic).toBe('epic2'); + expect(parsed.values?.summary).toBe('SUM MA RY'); + expect(parsed.values?.verbose).toBe('true'); + }}, + }, + { + title: 'happy full create case insensitive', + command: '/JiRa IsSuE CrEaTe --PrOjEcT `P 1` --SuMmArY "SUM MA RY" --VeRbOsE --EpIc=epic2', + 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.incomplete).toBe('epic2'); + expect(parsed.incompleteStart).toBe(75); + expect(parsed.values?.project).toBe('P 1'); + expect(parsed.values?.epic).toBeUndefined(); + expect(parsed.values?.summary).toBe('SUM MA RY'); + expect(parsed.values?.verbose).toBe('true'); + }}, + 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.values?.project).toBe('P 1'); + expect(parsed.values?.epic).toBe('epic2'); + expect(parsed.values?.summary).toBe('SUM MA RY'); + expect(parsed.values?.verbose).toBe('true'); + }}, + }, + { + title: 'partial epic', + command: '/jira issue create --project KT --summary "great feature" --epic M', + 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.incomplete).toBe('M'); + expect(parsed.incompleteStart).toBe(65); + expect(parsed.values?.project).toBe('KT'); + expect(parsed.values?.epic).toBeUndefined(); + }}, + 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.values?.epic).toBe('M'); + }}, + }, + + { + title: 'happy full view', + command: '/jira issue view --project=`P 1` MM-123', + 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.incomplete).toBe('MM-123'); + expect(parsed.incompleteStart).toBe(33); + expect(parsed.values?.project).toBe('P 1'); + expect(parsed.values?.issue).toBe(undefined); + }}, + 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.values?.project).toBe('P 1'); + expect(parsed.values?.issue).toBe('MM-123'); + }}, + }, + { + title: 'happy view no parameters', + command: '/jira issue view ', + 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.incomplete).toBe(''); + expect(parsed.incompleteStart).toBe(17); + expect(parsed.values).toEqual({}); + }}, + }, + { + title: 'happy create flag no value', + command: '/jira issue create --summary ', + 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.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.incomplete).toBe(''); + expect(parsed.values).toEqual({ + summary: '', + }); + }}, + }, + { + title: 'error: unmatched tick', + command: '/jira issue view --project `P 1', + 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.incomplete).toBe('P 1'); + expect(parsed.incompleteStart).toBe(27); + expect(parsed.values?.project).toBe(undefined); + expect(parsed.values?.issue).toBe(undefined); + }}, + submit: {expectError: 'Matching tick quote expected before end of input.'}, + }, + { + title: 'error: unmatched quote', + command: '/jira issue view --project "P \\1', + 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.incomplete).toBe('P 1'); + expect(parsed.incompleteStart).toBe(27); + expect(parsed.values?.project).toBe(undefined); + expect(parsed.values?.issue).toBe(undefined); + }}, + submit: {expectError: 'Matching double quote expected before end of input.'}, + }, + { + title: 'missing required fields not a problem for parseCommand', + command: '/jira issue view --project "P 1"', + 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.incomplete).toBe('P 1'); + expect(parsed.incompleteStart).toBe(27); + expect(parsed.values?.project).toBe(undefined); + expect(parsed.values?.issue).toBe(undefined); + }}, + 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.values?.project).toBe('P 1'); + expect(parsed.values?.issue).toBe(undefined); + }}, + }, + { + title: 'error: invalid flag', + command: '/jira issue view --wrong test', + submit: {expectError: 'Command does not accept flag `{flagName}`.'}, + }, + { + title: 'error: unexpected positional', + command: '/jira issue create wrong', + submit: {expectError: 'Command does not accept {positionX} positional arguments.'}, + }, + { + title: 'error: multiple equal signs', + command: '/jira issue create --project == test', + submit: {expectError: 'Multiple `=` signs are not allowed.'}, + }, + ]; + + table.forEach((tc) => { + test(tc.title, async () => { + const bindings = testBindings[0].bindings as AppBinding[]; + + let a = new ParsedCommand(tc.command, parser, intl); + a = await a.matchBinding(bindings, true); + a = a.parseForm(true); + checkResult(a, tc.autocomplete || tc.submit); + + let s = new ParsedCommand(tc.command, parser, intl); + s = await s.matchBinding(bindings, false); + s = s.parseForm(false); + checkResult(s, tc.submit); + }); + }); + }); + + describe('getSuggestions', () => { + test('just the app command', async () => { + const suggestions = await parser.getSuggestions('/jira'); + expect(suggestions).toEqual([]); + }); + + test('subcommand 1', async () => { + const suggestions = await parser.getSuggestions('/jira '); + expect(suggestions).toEqual([ + { + Suggestion: 'issue', + Complete: 'jira issue', + Hint: 'Issue hint', + IconData: 'Issue icon', + Description: 'Interact with Jira issues', + }, + ]); + }); + + test('subcommand 1 case insensitive', async () => { + const suggestions = await parser.getSuggestions('/JiRa '); + expect(suggestions).toEqual([ + { + Suggestion: 'issue', + Complete: 'JiRa issue', + Hint: 'Issue hint', + IconData: 'Issue icon', + Description: 'Interact with Jira issues', + }, + ]); + }); + + test('subcommand 2', async () => { + const suggestions = await parser.getSuggestions('/jira issue'); + expect(suggestions).toEqual([ + { + Suggestion: 'issue', + Complete: 'jira issue', + Hint: 'Issue hint', + IconData: 'Issue icon', + Description: 'Interact with Jira issues', + }, + ]); + }); + + test('subcommand 2 case insensitive', async () => { + const suggestions = await parser.getSuggestions('/JiRa IsSuE'); + expect(suggestions).toEqual([ + { + Suggestion: 'issue', + Complete: 'JiRa issue', + Hint: 'Issue hint', + IconData: 'Issue icon', + Description: 'Interact with Jira issues', + }, + ]); + }); + + test('subcommand 2 with a space', async () => { + const suggestions = await parser.getSuggestions('/jira issue '); + expect(suggestions).toEqual([ + { + Suggestion: 'view', + Complete: 'jira issue view', + Hint: '', + IconData: '', + Description: 'View details of a Jira issue', + }, + { + Suggestion: 'create', + Complete: 'jira issue create', + Hint: 'Create hint', + IconData: 'Create icon', + Description: 'Create a new Jira issue', + }, + ]); + }); + + test('subcommand 2 with a space case insensitive', async () => { + const suggestions = await parser.getSuggestions('/JiRa IsSuE '); + expect(suggestions).toEqual([ + { + Suggestion: 'view', + Complete: 'JiRa IsSuE view', + Hint: '', + IconData: '', + Description: 'View details of a Jira issue', + }, + { + Suggestion: 'create', + Complete: 'JiRa IsSuE create', + Hint: 'Create hint', + IconData: 'Create icon', + Description: 'Create a new Jira issue', + }, + ]); + }); + + test('subcommand 3 partial', async () => { + const suggestions = await parser.getSuggestions('/jira issue c'); + expect(suggestions).toEqual([ + { + Suggestion: 'create', + Complete: 'jira issue create', + Hint: 'Create hint', + IconData: 'Create icon', + Description: 'Create a new Jira issue', + }, + ]); + }); + + test('subcommand 3 partial case insensitive', async () => { + const suggestions = await parser.getSuggestions('/JiRa IsSuE C'); + expect(suggestions).toEqual([ + { + Suggestion: 'create', + Complete: 'JiRa IsSuE create', + Hint: 'Create hint', + IconData: 'Create icon', + Description: 'Create a new Jira issue', + }, + ]); + }); + + test('view just after subcommand (positional)', async () => { + const suggestions = await parser.getSuggestions('/jira issue view '); + expect(suggestions).toEqual([ + { + Complete: 'jira issue view', + Description: 'The Jira issue key', + Hint: '', + IconData: '', + Suggestion: '', + }, + ]); + }); + + test('view flags just after subcommand', async () => { + let suggestions = await parser.getSuggestions('/jira issue view -'); + expect(suggestions).toEqual([ + { + Complete: 'jira issue view --project', + Description: 'The Jira project description', + Hint: 'The Jira project hint', + IconData: '', + Suggestion: '--project', + }, + ]); + + suggestions = await parser.getSuggestions('/jira issue view --'); + expect(suggestions).toEqual([ + { + Complete: 'jira issue view --project', + Description: 'The Jira project description', + Hint: 'The Jira project hint', + IconData: '', + Suggestion: '--project', + }, + ]); + }); + + test('create flags just after subcommand', async () => { + const suggestions = await parser.getSuggestions('/jira issue create '); + + let executeCommand: AutocompleteSuggestion[] = []; + if (checkForExecuteSuggestion) { + executeCommand = [ + { + Complete: 'jira issue create _execute_current_command', + Description: 'Select this option or use Ctrl+Enter to execute the current command.', + Hint: '', + IconData: '_execute_current_command', + Suggestion: 'Execute Current Command', + }, + ]; + } + + expect(suggestions).toEqual([ + ...executeCommand, + { + Complete: 'jira issue create --project', + Description: 'The Jira project description', + Hint: 'The Jira project hint', + IconData: 'Create icon', + Suggestion: '--project', + }, + { + Complete: 'jira issue create --summary', + Description: 'The Jira issue summary', + Hint: 'The thing is working great!', + IconData: 'Create icon', + Suggestion: '--summary', + }, + { + Complete: 'jira issue create --verbose', + Description: 'display details', + Hint: 'yes or no!', + IconData: 'Create icon', + Suggestion: '--verbose', + }, + { + Complete: 'jira issue create --epic', + Description: 'The Jira epic', + Hint: 'The thing is working great!', + IconData: 'Create icon', + Suggestion: '--epic', + }, + ]); + }); + + test('used flags do not appear', async () => { + const suggestions = await parser.getSuggestions('/jira issue create --project KT '); + + let executeCommand: AutocompleteSuggestion[] = []; + if (checkForExecuteSuggestion) { + executeCommand = [ + { + Complete: 'jira issue create --project KT _execute_current_command', + Description: 'Select this option or use Ctrl+Enter to execute the current command.', + Hint: '', + IconData: '_execute_current_command', + Suggestion: 'Execute Current Command', + }, + ]; + } + + expect(suggestions).toEqual([ + ...executeCommand, + { + Complete: 'jira issue create --project KT --summary', + Description: 'The Jira issue summary', + Hint: 'The thing is working great!', + IconData: 'Create icon', + Suggestion: '--summary', + }, + { + Complete: 'jira issue create --project KT --verbose', + Description: 'display details', + Hint: 'yes or no!', + IconData: 'Create icon', + Suggestion: '--verbose', + }, + { + Complete: 'jira issue create --project KT --epic', + Description: 'The Jira epic', + Hint: 'The thing is working great!', + IconData: 'Create icon', + Suggestion: '--epic', + }, + ]); + }); + + test('create flags mid-flag', async () => { + const mid = await parser.getSuggestions('/jira issue create --project KT --summ'); + expect(mid).toEqual([ + { + Complete: 'jira issue create --project KT --summary', + Description: 'The Jira issue summary', + Hint: 'The thing is working great!', + IconData: 'Create icon', + Suggestion: '--summary', + }, + ]); + + const full = await parser.getSuggestions('/jira issue create --project KT --summary'); + expect(full).toEqual([ + { + Complete: 'jira issue create --project KT --summary', + Description: 'The Jira issue summary', + Hint: 'The thing is working great!', + IconData: 'Create icon', + Suggestion: '--summary', + }, + ]); + }); + + test('empty text value suggestion', async () => { + const suggestions = await parser.getSuggestions('/jira issue create --project KT --summary '); + expect(suggestions).toEqual([ + { + Complete: 'jira issue create --project KT --summary', + Description: 'The Jira issue summary', + Hint: '', + IconData: 'Create icon', + Suggestion: '', + }, + ]); + }); + + test('partial text value suggestion', async () => { + const suggestions = await parser.getSuggestions('/jira issue create --project KT --summary Sum'); + expect(suggestions).toEqual([ + { + Complete: 'jira issue create --project KT --summary Sum', + Description: 'The Jira issue summary', + Hint: '', + IconData: 'Create icon', + Suggestion: 'Sum', + }, + ]); + }); + + test('quote text value suggestion close quotes', async () => { + const suggestions = await parser.getSuggestions('/jira issue create --project KT --summary "Sum'); + expect(suggestions).toEqual([ + { + Complete: 'jira issue create --project KT --summary "Sum"', + Description: 'The Jira issue summary', + Hint: '', + IconData: 'Create icon', + Suggestion: 'Sum', + }, + ]); + }); + + test('tick text value suggestion close quotes', async () => { + const suggestions = await parser.getSuggestions('/jira issue create --project KT --summary `Sum'); + expect(suggestions).toEqual([ + { + Complete: 'jira issue create --project KT --summary `Sum`', + Description: 'The Jira issue summary', + Hint: '', + IconData: 'Create icon', + Suggestion: 'Sum', + }, + ]); + }); + + test('create flag summary value', async () => { + const suggestions = await parser.getSuggestions('/jira issue create --summary '); + expect(suggestions).toEqual([ + { + Complete: 'jira issue create --summary', + Description: 'The Jira issue summary', + Hint: '', + IconData: 'Create icon', + Suggestion: '', + }, + ]); + }); + + test('create flag project dynamic select value', async () => { + const f = Client4.executeAppCall; + Client4.executeAppCall = jest.fn().mockResolvedValue(Promise.resolve({type: AppCallResponseTypes.OK, data: {items: [{label: 'special-label', value: 'special-value'}]}})); + + const suggestions = await parser.getSuggestions('/jira issue create --project '); + Client4.executeAppCall = f; + + expect(suggestions).toEqual([ + { + Complete: 'jira issue create --project special-value', + Suggestion: 'special-value', + Description: 'special-label', + Hint: '', + IconData: 'Create icon', + }, + ]); + }); + + test('create flag epic static select value', async () => { + let suggestions = await parser.getSuggestions('/jira issue create --project KT --summary "great feature" --epic '); + expect(suggestions).toEqual([ + { + Complete: 'jira issue create --project KT --summary "great feature" --epic epic1', + Suggestion: 'Dylan Epic', + Description: 'The Jira epic', + Hint: 'The thing is working great!', + IconData: 'Create icon', + }, + { + Complete: 'jira issue create --project KT --summary "great feature" --epic epic2', + Suggestion: 'Michael Epic', + Description: 'The Jira epic', + Hint: 'The thing is working great!', + IconData: 'Create icon', + }, + ]); + + suggestions = await parser.getSuggestions('/jira issue create --project KT --summary "great feature" --epic M'); + expect(suggestions).toEqual([ + { + Complete: 'jira issue create --project KT --summary "great feature" --epic epic2', + Suggestion: 'Michael Epic', + Description: 'The Jira epic', + Hint: 'The thing is working great!', + IconData: 'Create icon', + }, + ]); + + suggestions = await parser.getSuggestions('/jira issue create --project KT --summary "great feature" --epic Nope'); + expect(suggestions).toEqual([ + { + Complete: 'jira issue create --project KT --summary "great feature" --epic', + Suggestion: '', + Description: 'No matching options.', + Hint: '', + IconData: '', + }, + ]); + }); + + test('filled out form shows execute', async () => { + const suggestions = await parser.getSuggestions('/jira issue create --project KT --summary "great feature" --epic epicvalue --verbose true '); + + if (!checkForExecuteSuggestion) { + expect(suggestions).toEqual([]); + return; + } + + expect(suggestions).toEqual([ + { + Complete: 'jira issue create --project KT --summary "great feature" --epic epicvalue --verbose true _execute_current_command', + Suggestion: 'Execute Current Command', + Description: 'Select this option or use Ctrl+Enter to execute the current command.', + IconData: '_execute_current_command', + Hint: '', + }, + ]); + }); + }); + + describe('composeCallFromCommand', () => { + const base = { + context: { + app_id: 'jira', + channel_id: 'current_channel_id', + location: '/command', + root_id: 'root_id', + team_id: 'team_id', + }, + path: '/create-issue', + }; + + test('empty form', async () => { + const cmd = '/jira issue create'; + const values = {}; + + const call = await parser.composeCallFromCommand(cmd); + expect(call).toEqual({ + ...base, + raw_command: cmd, + expand: {}, + query: undefined, + selected_field: undefined, + values, + }); + }); + + test('full form', async () => { + const cmd = '/jira issue create --summary "Here it is" --epic epic1 --verbose true --project'; + const values = { + summary: 'Here it is', + epic: { + label: 'Dylan Epic', + value: 'epic1', + }, + verbose: 'true', + project: '', + }; + + const call = await parser.composeCallFromCommand(cmd); + expect(call).toEqual({ + ...base, + expand: {}, + selected_field: undefined, + query: undefined, + raw_command: cmd, + values, + }); + }); + + test('dynamic lookup test', async () => { + const f = Client4.executeAppCall; + + const mockedExecute = jest.fn().mockResolvedValue(Promise.resolve({type: AppCallResponseTypes.OK, data: {items: [{label: 'special-label', value: 'special-value'}]}})); + Client4.executeAppCall = mockedExecute; + + const suggestions = await parser.getSuggestions('/jira issue create --summary "The summary" --epic epic1 --project special'); + Client4.executeAppCall = f; + + expect(suggestions).toEqual([ + { + Complete: 'jira issue create --summary "The summary" --epic epic1 --project special-value', + Suggestion: 'special-value', + Description: 'special-label', + Hint: '', + IconData: 'Create icon', + }, + ]); + + expect(mockedExecute).toHaveBeenCalledWith({ + context: { + app_id: 'jira', + channel_id: 'current_channel_id', + location: '/command', + root_id: 'root_id', + team_id: 'team_id', + }, + expand: {}, + path: '/create-issue', + query: 'special', + raw_command: '/jira issue create --summary "The summary" --epic epic1 --project special', + selected_field: 'project', + values: { + summary: 'The summary', + epic: { + label: 'Dylan Epic', + value: 'epic1', + }, + }, + }, AppCallTypes.LOOKUP); + }); + }); +}); 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 new file mode 100644 index 000000000..0b1f17683 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts @@ -0,0 +1,1247 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable max-lines */ + +import {intlShape} from 'react-intl'; + +import { + AppCallRequest, + AppBinding, + AppField, + AppSelectOption, + AppCallResponse, + AppCallValues, + AppContext, + AppForm, + AutocompleteSuggestion, + AutocompleteStaticSelect, + Channel, + DispatchFunc, + GlobalState, + + AppBindingLocations, + AppCallResponseTypes, + AppCallTypes, + AppFieldTypes, + getAppsBindings, + getChannel, + getCurrentTeamId, + doAppCall, + getStore, + EXECUTE_CURRENT_COMMAND_ITEM_ID, + getExecuteSuggestion, + keyMirror, + createCallRequest, + displayError, + selectChannelByName, + selectUserByUsername, + getUserByUsername, + getChannelByNameAndTeamName, + getCurrentTeam, +} from './app_command_parser_dependencies'; + +export type Store = { + dispatch: DispatchFunc; + getState: () => GlobalState; +} + +export const ParseState = keyMirror({ + Start: null, + Command: null, + EndCommand: null, + CommandSeparator: null, + StartParameter: null, + ParameterSeparator: null, + Flag1: null, + Flag: null, + FlagValueSeparator: null, + StartValue: null, + NonspaceValue: null, + QuotedValue: null, + TickValue: null, + EndValue: null, + EndQuotedValue: null, + EndTickedValue: null, + Error: null, +}); + +interface FormsCache { + getForm: (location: string, binding: AppBinding) => Promise; +} + +export class ParsedCommand { + state: string = ParseState.Start; + command: string; + i = 0; + incomplete = ''; + incompleteStart = 0; + binding: AppBinding | undefined; + form: AppForm | undefined; + formsCache: FormsCache; + field: AppField | undefined; + position = 0; + values: {[name: string]: string} = {}; + location = ''; + error = ''; + intl: typeof intlShape; + + constructor(command: string, formsCache: FormsCache, intl: any) { + this.command = command; + this.formsCache = formsCache || []; + this.intl = intl; + } + + asError = (message: string): ParsedCommand => { + this.state = ParseState.Error; + this.error = message; + return this; + }; + + errorMessage = (): string => { + return this.intl.formatMessage({ + id: 'apps.error.parser', + defaultMessage: 'Parsing error: {error}.\n```\n{command}\n{space}^\n```', + }, { + error: this.error, + command: this.command, + space: ' '.repeat(this.i), + }); + } + + // matchBinding finds the closest matching command binding. + matchBinding = async (commandBindings: AppBinding[], autocompleteMode = false): Promise => { + if (commandBindings.length === 0) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.no_bindings', + defaultMessage: 'No command bindings.', + })); + } + let bindings = commandBindings; + + let done = false; + while (!done) { + let c = ''; + if (this.i < this.command.length) { + c = this.command[this.i]; + } + + switch (this.state) { + case ParseState.Start: { + if (c !== '/') { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.no_slash_start', + defaultMessage: 'Command must start with a `/`.', + })); + } + this.i++; + this.incomplete = ''; + this.incompleteStart = this.i; + this.state = ParseState.Command; + break; + } + + case ParseState.Command: { + switch (c) { + case '': { + if (autocompleteMode) { + // Finish in the Command state, 'incomplete' will have the query string + done = true; + } else { + this.state = ParseState.EndCommand; + } + break; + } + case ' ': + case '\t': { + this.state = ParseState.EndCommand; + break; + } + default: + this.incomplete += c; + this.i++; + break; + } + break; + } + + case ParseState.EndCommand: { + const binding = bindings.find((b: AppBinding) => b.label === this.incomplete.toLowerCase()); + if (!binding) { + // gone as far as we could, this token doesn't match a sub-command. + // return the state from the last matching binding + done = true; + break; + } + this.binding = binding; + this.location += '/' + binding.label; + bindings = binding.bindings || []; + this.state = ParseState.CommandSeparator; + break; + } + + case ParseState.CommandSeparator: { + if (c === '') { + done = true; + } + + switch (c) { + case ' ': + case '\t': { + this.i++; + break; + } + default: { + this.incomplete = ''; + this.incompleteStart = this.i; + this.state = ParseState.Command; + break; + } + } + break; + } + + default: { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_state', + defaultMessage: 'Unreachable: Unexpected state in matchBinding: `{state}`.', + }, { + state: this.state, + })); + } + } + } + + if (!this.binding) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.no_match', + defaultMessage: '`{command}`: No matching command found in this workspace.', + }, { + command: this.command, + })); + } + + this.form = this.binding.form; + if (!this.form) { + this.form = await this.formsCache.getForm(this.location, this.binding); + } + + return this; + } + + // parseForm parses the rest of the command using the previously matched form. + parseForm = (autocompleteMode = false): ParsedCommand => { + if (this.state === ParseState.Error || !this.form) { + return this; + } + + let fields: AppField[] = []; + if (this.form.fields) { + fields = this.form.fields; + } + + this.state = ParseState.StartParameter; + this.i = this.incompleteStart || 0; + let flagEqualsUsed = false; + let escaped = false; + + // eslint-disable-next-line no-constant-condition + while (true) { + let c = ''; + if (this.i < this.command.length) { + c = this.command[this.i]; + } + + switch (this.state) { + case ParseState.StartParameter: { + switch (c) { + case '': + return this; + case '-': { + // Named parameter (aka Flag). Flag1 consumes the optional second '-'. + this.state = ParseState.Flag1; + this.i++; + break; + } + default: { + // Positional parameter. + this.position++; + // eslint-disable-next-line no-loop-func + const field = fields.find((f: AppField) => f.position === this.position); + if (!field) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.no_argument_pos_x', + defaultMessage: 'Command does not accept {positionX} positional arguments.', + }, { + positionX: this.position, + })); + } + this.field = field; + this.state = ParseState.StartValue; + break; + } + } + break; + } + + case ParseState.ParameterSeparator: { + this.incompleteStart = this.i; + switch (c) { + case '': + this.state = ParseState.StartParameter; + return this; + case ' ': + case '\t': { + this.i++; + break; + } + default: + this.state = ParseState.StartParameter; + break; + } + break; + } + + case ParseState.Flag1: { + // consume the optional second '-' + if (c === '-') { + this.i++; + } + this.state = ParseState.Flag; + this.incomplete = ''; + this.incompleteStart = this.i; + flagEqualsUsed = false; + break; + } + + case ParseState.Flag: { + if (c === '' && autocompleteMode) { + return this; + } + + switch (c) { + case '': + case ' ': + case '\t': + case '=': { + const field = fields.find((f) => f.label === this.incomplete.toLowerCase()); + if (!field) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_flag', + defaultMessage: 'Command does not accept flag `{flagName}`.', + }, { + flagName: this.incomplete, + })); + } + this.state = ParseState.FlagValueSeparator; + this.field = field; + this.incomplete = ''; + break; + } + default: { + this.incomplete += c; + this.i++; + break; + } + } + break; + } + + case ParseState.FlagValueSeparator: { + this.incompleteStart = this.i; + switch (c) { + case '': { + if (autocompleteMode) { + return this; + } + this.state = ParseState.StartValue; + break; + } + case ' ': + case '\t': { + this.i++; + break; + } + case '=': { + if (flagEqualsUsed) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.multiple_equal', + defaultMessage: 'Multiple `=` signs are not allowed.', + })); + } + flagEqualsUsed = true; + this.i++; + break; + } + default: { + this.state = ParseState.StartValue; + } + } + break; + } + + case ParseState.StartValue: { + this.incomplete = ''; + this.incompleteStart = this.i; + switch (c) { + case '"': { + this.state = ParseState.QuotedValue; + this.i++; + break; + } + case '`': { + this.state = ParseState.TickValue; + this.i++; + break; + } + case ' ': + case '\t': + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_whitespace', + defaultMessage: 'Unreachable: Unexpected whitespace.', + })); + default: { + this.state = ParseState.NonspaceValue; + break; + } + } + break; + } + + case ParseState.NonspaceValue: { + switch (c) { + case '': + case ' ': + case '\t': { + this.state = ParseState.EndValue; + break; + } + default: { + this.incomplete += c; + this.i++; + break; + } + } + break; + } + + case ParseState.QuotedValue: { + 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.EndQuotedValue; + 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.TickValue: { + 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.EndTickedValue; + break; + } + default: { + this.incomplete += c; + this.i++; + break; + } + } + break; + } + + case ParseState.EndTickedValue: + case ParseState.EndQuotedValue: + case ParseState.EndValue: { + if (!this.field) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_field_value', + defaultMessage: 'Field value is missing.', + })); + } + + // special handling for optional BOOL values ('--boolflag true' + // vs '--boolflag next-positional' vs '--boolflag + // --next-flag...') + if (this.field.type === AppFieldTypes.BOOL && + ((autocompleteMode && !'true'.startsWith(this.incomplete) && !'false'.startsWith(this.incomplete)) || + (!autocompleteMode && this.incomplete !== 'true' && this.incomplete !== 'false'))) { + // reset back where the value started, and treat as a new parameter + this.i = this.incompleteStart; + this.values![this.field.name] = 'true'; + this.state = ParseState.StartParameter; + } else { + if (autocompleteMode && c === '') { + return this; + } + this.values![this.field.name] = this.incomplete; + this.incomplete = ''; + this.incompleteStart = this.i; + if (c === '') { + return this; + } + this.state = ParseState.ParameterSeparator; + } + break; + } + } + } + } +} + +export class AppCommandParser { + private store: Store; + private channelID: string; + private rootPostID?: string; + private intl: typeof intlShape; + + forms: {[location: string]: AppForm} = {}; + + constructor(store: Store|null, intl: typeof intlShape, channelID: string, rootPostID = '') { + this.store = store || getStore() as Store; + this.channelID = channelID; + this.rootPostID = rootPostID; + this.intl = intl; + } + + // composeCallFromCommand creates the form submission call + public composeCallFromCommand = async (command: string): Promise => { + let parsed = new ParsedCommand(command, this, this.intl); + + const commandBindings = this.getCommandBindings(); + if (!commandBindings) { + this.displayError(this.intl.formatMessage({ + id: 'apps.error.command.no_bindings', + defaultMessage: 'No command bindings.', + })); + return null; + } + + parsed = await parsed.matchBinding(commandBindings, false); + parsed = parsed.parseForm(false); + if (parsed.state === ParseState.Error) { + this.displayError(parsed.errorMessage()); + return null; + } + + const missing = this.getMissingFields(parsed); + if (missing.length > 0) { + const missingStr = missing.map((f) => f.label).join(', '); + this.displayError(this.intl.formatMessage({ + id: 'apps.error.command.field_missing', + defaultMessage: 'Required fields missing: `{fieldName}`.', + }, { + fieldName: missingStr, + })); + return null; + } + + return this.composeCallFromParsed(parsed); + } + + // getSuggestionsBase is a synchronous function that returns results for base commands + public getSuggestionsBase = (pretext: string): AutocompleteSuggestion[] => { + const command = pretext.toLowerCase(); + const result: AutocompleteSuggestion[] = []; + + const bindings = this.getCommandBindings(); + for (const binding of bindings) { + let base = binding.app_id; + if (!base) { + continue; + } + + if (base[0] !== '/') { + base = '/' + base; + } + if (base.startsWith(command)) { + result.push({ + Suggestion: base, + Complete: base.substring(1), + Description: binding.description || '', + Hint: binding.hint || '', + IconData: binding.icon || '', + }); + } + } + + return result; + } + + // getSuggestions returns suggestions for subcommands and/or form arguments + public getSuggestions = async (pretext: string): Promise => { + let parsed = new ParsedCommand(pretext, this, this.intl); + + const commandBindings = this.getCommandBindings(); + if (!commandBindings) { + return []; + } + + parsed = await parsed.matchBinding(commandBindings, true); + let suggestions: AutocompleteSuggestion[] = []; + if (parsed.state === ParseState.Command) { + suggestions = this.getCommandSuggestions(parsed); + } + + if (parsed.form || parsed.incomplete) { + parsed = parsed.parseForm(true); + const argSuggestions = await this.getParameterSuggestions(parsed); + suggestions = suggestions.concat(argSuggestions); + } + + // Add "Execute Current Command" suggestion + // TODO get full text from SuggestionBox + const executableStates: string[] = [ + ParseState.EndCommand, + ParseState.CommandSeparator, + ParseState.StartParameter, + ParseState.ParameterSeparator, + ParseState.EndValue, + ]; + const call = parsed.form?.call || parsed.binding?.call || parsed.binding?.form?.call; + const hasRequired = this.getMissingFields(parsed).length === 0; + const hasValue = (parsed.state !== ParseState.EndValue || (parsed.field && parsed.values[parsed.field.name] !== undefined)); + + if (executableStates.includes(parsed.state) && call && hasRequired && hasValue) { + const execute = getExecuteSuggestion(parsed); + if (execute) { + suggestions = [execute, ...suggestions]; + } + } + + return suggestions.map((suggestion) => this.decorateSuggestionComplete(parsed, suggestion)); + } + + // composeCallFromParsed creates the form submission call + composeCallFromParsed = async (parsed: ParsedCommand): Promise => { + if (!parsed.binding) { + return null; + } + + const call = parsed.form?.call || parsed.binding.call; + if (!call) { + return null; + } + + const values: AppCallValues = parsed.values; + const ok = await this.expandOptions(parsed, values); + + if (!ok) { + return null; + } + + const context = this.getAppContext(parsed.binding.app_id); + return createCallRequest(call, context, {}, values, parsed.command); + } + + expandOptions = async (parsed: ParsedCommand, values: AppCallValues) => { + if (!parsed.form || !parsed.form.fields) { + return true; + } + + let ok = true; + await Promise.all(parsed.form.fields.map(async (f) => { + if (!values[f.name]) { + return; + } + switch (f.type) { + case AppFieldTypes.DYNAMIC_SELECT: + values[f.name] = {label: '', value: values[f.name]}; + break; + case AppFieldTypes.STATIC_SELECT: { + const option = f.options?.find((o) => (o.value === values[f.name])); + if (!option) { + ok = false; + this.displayError(this.intl.formatMessage({ + id: 'apps.error.command.unknown_option', + defaultMessage: 'Unknown option for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: values[f.name], + })); + return; + } + values[f.name] = option; + break; + } + case AppFieldTypes.USER: { + let userName = values[f.name] as string; + if (userName[0] === '@') { + userName = userName.substr(1); + } + let user = selectUserByUsername(this.store.getState(), userName); + if (!user) { + const dispatchResult = await this.store.dispatch(getUserByUsername(userName) as any); + if ('error' in dispatchResult) { + ok = false; + this.displayError(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; + } + values[f.name] = {label: user.username, value: user.id}; + break; + } + case AppFieldTypes.CHANNEL: { + let channelName = values[f.name] as string; + if (channelName[0] === '~') { + channelName = channelName.substr(1); + } + 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) { + ok = false; + this.displayError(this.intl.formatMessage({ + id: 'apps.error.command.unknown_channel', + defaultMessage: 'Unknown channel for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: values[f.name], + })); + } + channel = dispatchResult.data; + } + values[f.name] = {label: channel?.display_name, value: channel?.id}; + break; + } + } + })); + + return ok; + } + + // decorateSuggestionComplete applies the necessary modifications for a suggestion to be processed + decorateSuggestionComplete = (parsed: ParsedCommand, choice: AutocompleteSuggestion): AutocompleteSuggestion => { + if (choice.Complete && choice.Complete.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID)) { + return choice as AutocompleteSuggestion; + } + + let goBackSpace = 0; + if (choice.Complete === '') { + goBackSpace = 1; + } + let complete = parsed.command.substring(0, parsed.incompleteStart - goBackSpace); + complete += choice.Complete || choice.Suggestion; + choice.Hint = choice.Hint || ''; + complete = complete.substring(1); + + return { + ...choice, + Complete: complete, + }; + } + + // getCommandBindings returns the commands in the redux store. + // They are grouped by app id since each app has one base command + getCommandBindings = (): AppBinding[] => { + const bindings = getAppsBindings(this.store.getState(), AppBindingLocations.COMMAND); + return bindings; + } + + // getChannel gets the channel in which the user is typing the command + getChannel = (): Channel | null => { + const state = this.store.getState(); + return getChannel(state, this.channelID); + } + + setChannelContext = (channelID: string, rootPostID?: string) => { + this.channelID = channelID; + this.rootPostID = rootPostID; + } + + // isAppCommand determines if subcommand/form suggestions need to be returned + isAppCommand = (pretext: string): boolean => { + const command = pretext.toLowerCase(); + for (const binding of this.getCommandBindings()) { + let base = binding.app_id; + if (!base) { + continue; + } + + if (base[0] !== '/') { + base = '/' + base; + } + + if (command.startsWith(base + ' ')) { + return true; + } + } + return false; + } + + // getAppContext collects post/channel/team info for performing calls + getAppContext = (appID: string): AppContext => { + const context: AppContext = { + app_id: appID, + location: AppBindingLocations.COMMAND, + root_id: this.rootPostID, + }; + + const channel = this.getChannel(); + if (!channel) { + return context; + } + + context.channel_id = channel.id; + context.team_id = channel.team_id || getCurrentTeamId(this.store.getState()); + + return context; + } + + // fetchForm unconditionaly retrieves the form for the given binding (subcommand) + fetchForm = async (binding: AppBinding): Promise => { + if (!binding.call) { + return undefined; + } + + const payload = createCallRequest( + binding.call, + this.getAppContext(binding.app_id), + ); + + const res = await this.store.dispatch(doAppCall(payload, AppCallTypes.FORM, this.intl)); + if (res.error) { + const errorResponse = res.error as AppCallResponse; + this.displayError(errorResponse.error || this.intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error.', + })); + return undefined; + } + + const callResponse = res.data as AppCallResponse; + switch (callResponse.type) { + case AppCallResponseTypes.FORM: + break; + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.OK: + this.displayError(this.intl.formatMessage({ + id: 'apps.error.responses.unexpected_type', + defaultMessage: 'App response type was not expected. Response type: {type}', + }, { + type: callResponse.type, + })); + return undefined; + default: + this.displayError(this.intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResponse.type, + })); + return undefined; + } + + return res.data.form; + } + + getForm = async (location: string, binding: AppBinding): Promise => { + const form = this.forms[location]; + if (form) { + return form; + } + + const fetched = await this.fetchForm(binding); + if (fetched) { + this.forms[location] = fetched; + } + return fetched; + } + + // displayError shows an error that was caught by the parser + displayError = (err: any): void => { + let errStr = err as string; + if (err.message) { + errStr = err.message; + } + + displayError(this.intl, errStr); + } + + // getSuggestionsForSubCommands returns suggestions for a subcommand's name + getCommandSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + if (!parsed.binding?.bindings?.length) { + return []; + } + const bindings = parsed.binding.bindings; + const result: AutocompleteSuggestion[] = []; + + bindings.forEach((b) => { + if (b.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())) { + result.push({ + Complete: b.label, + Suggestion: b.label, + Description: b.description || '', + Hint: b.hint || '', + IconData: b.icon || '', + }); + } + }); + + return result; + } + + // getParameterSuggestions computes suggestions for positional argument values, flag names, and flag argument values + getParameterSuggestions = async (parsed: ParsedCommand): Promise => { + 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); + if (positional) { + parsed.field = positional; + return this.getValueSuggestions(parsed); + } + return this.getFlagNameSuggestions(parsed); + } + + case ParseState.Flag: + return this.getFlagNameSuggestions(parsed); + + case ParseState.EndValue: + case ParseState.FlagValueSeparator: + case ParseState.NonspaceValue: + return this.getValueSuggestions(parsed); + case ParseState.EndQuotedValue: + case ParseState.QuotedValue: + return this.getValueSuggestions(parsed, '"'); + case ParseState.EndTickedValue: + case ParseState.TickValue: + return this.getValueSuggestions(parsed, '`'); + } + return []; + } + + // getMissingFields collects the required fields that were not supplied in a submission + getMissingFields = (parsed: ParsedCommand): AppField[] => { + const form = parsed.form; + if (!form) { + return []; + } + + const missing: AppField[] = []; + + const values = parsed.values || []; + const fields = form.fields || []; + for (const field of fields) { + if (field.is_required && !values[field.name]) { + missing.push(field); + } + } + + return missing; + } + + // getFlagNameSuggestions returns suggestions for flag names + getFlagNameSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + if (!parsed.form || !parsed.form.fields || !parsed.form.fields.length) { + return []; + } + + // There have been 0 to 2 dashes in the command prior to this call, adjust. + let prefix = '--'; + for (let i = parsed.incompleteStart - 1; i > 0 && i >= parsed.incompleteStart - 2 && parsed.command[i] === '-'; i--) { + prefix = prefix.substring(1); + } + + const applicable = parsed.form.fields.filter((field) => field.label && field.label.startsWith(parsed.incomplete.toLowerCase()) && !parsed.values[field.name]); + if (applicable) { + return applicable.map((f) => { + return { + Complete: prefix + (f.label || f.name), + Suggestion: '--' + (f.label || f.name), + Description: f.description || '', + Hint: f.hint || '', + IconData: parsed.binding?.icon || '', + }; + }); + } + + return []; + } + + // getSuggestionsForField gets suggestions for a positional or flag field value + getValueSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => { + if (!parsed || !parsed.field) { + return []; + } + const f = parsed.field; + + switch (f.type) { + case AppFieldTypes.USER: + return this.getUserSuggestions(parsed); + case AppFieldTypes.CHANNEL: + return this.getChannelSuggestions(parsed); + case AppFieldTypes.BOOL: + return this.getBooleanSuggestions(parsed); + case AppFieldTypes.DYNAMIC_SELECT: + return this.getDynamicSelectSuggestions(parsed, delimiter); + case AppFieldTypes.STATIC_SELECT: + return this.getStaticSelectSuggestions(parsed, delimiter); + } + + let complete = parsed.incomplete; + if (complete && delimiter) { + complete = delimiter + complete + delimiter; + } + + return [{ + Complete: complete, + Suggestion: parsed.incomplete, + Description: f.description || '', + Hint: '', + IconData: parsed.binding?.icon || '', + }]; + } + + // getStaticSelectSuggestions returns suggestions specified in the field's options property + getStaticSelectSuggestions = (parsed: ParsedCommand, delimiter?: string): AutocompleteSuggestion[] => { + const f = parsed.field as AutocompleteStaticSelect; + const opts = f.options?.filter((opt) => opt.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())); + if (!opts?.length) { + return [{ + Complete: '', + Suggestion: '', + Hint: '', + Description: this.intl.formatMessage({ + id: 'apps.suggestion.no_static', + defaultMessage: 'No matching options.', + }), + IconData: '', + }]; + } + return opts.map((opt) => { + let complete = opt.value; + if (delimiter) { + complete = delimiter + complete + delimiter; + } else if (isMultiword(opt.value)) { + complete = '`' + complete + '`'; + } + return { + Complete: complete, + Suggestion: opt.label, + Hint: f.hint || '', + Description: f.description || '', + IconData: opt.icon_data || parsed.binding?.icon || '', + }; + }); + } + + // getDynamicSelectSuggestions fetches and returns suggestions from the server + getDynamicSelectSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => { + const f = parsed.field; + if (!f) { + // Should never happen + return this.makeSuggestionError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_error', + defaultMessage: 'Unexpected error.', + })); + } + + const call = await this.composeCallFromParsed(parsed); + if (!call) { + return this.makeSuggestionError(this.intl.formatMessage({ + id: 'apps.error.lookup.error_preparing_request', + defaultMessage: 'Error preparing lookup request.', + })); + } + call.selected_field = f.name; + call.query = parsed.incomplete; + + type ResponseType = {items: AppSelectOption[]}; + const res = await this.store.dispatch(doAppCall(call, AppCallTypes.LOOKUP, this.intl)); + if (res.error) { + const errorResponse = res.error as AppCallResponse; + return this.makeSuggestionError(errorResponse.error || this.intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error.', + })); + } + + const callResponse = res.data as AppCallResponse; + switch (callResponse.type) { + case AppCallResponseTypes.OK: + break; + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.FORM: + return this.makeSuggestionError(this.intl.formatMessage({ + id: 'apps.error.responses.unexpected_type', + defaultMessage: 'App response type was not expected. Response type: {type}', + }, { + type: callResponse.type, + })); + default: + return this.makeSuggestionError(this.intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResponse.type, + })); + } + + const items = callResponse?.data?.items; + if (!items?.length) { + return [{ + Complete: '', + Suggestion: '', + Hint: '', + IconData: '', + Description: this.intl.formatMessage({ + id: 'apps.suggestion.no_dynamic', + defaultMessage: 'No data was returned for dynamic suggestions', + }), + }]; + } + + return items.map((s): AutocompleteSuggestion => { + let complete = s.value; + if (delimiter) { + complete = delimiter + complete + delimiter; + } else if (isMultiword(s.value)) { + complete = '`' + complete + '`'; + } + return ({ + Complete: complete, + Description: s.label, + Suggestion: s.value, + Hint: '', + IconData: s.icon_data || parsed.binding?.icon || '', + }); + }); + } + + makeSuggestionError = (message: string): AutocompleteSuggestion[] => { + const errMsg = this.intl.formatMessage({ + id: 'apps.error', + defaultMessage: 'Error: {error}', + }, { + error: message, + }); + return [{ + Complete: '', + Suggestion: '', + Hint: '', + IconData: '', + Description: errMsg, + }]; + } + + // getUserSuggestions returns a suggestion with `@` if the user has not started typing + getUserSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + if (parsed.incomplete.trim().length === 0) { + return [{ + Complete: '', + Suggestion: '', + Description: parsed.field?.description || '', + Hint: parsed.field?.hint || '@username', + IconData: parsed.binding?.icon || '', + }]; + } + + return []; + } + + // getChannelSuggestions returns a suggestion with `~` if the user has not started typing + getChannelSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + if (parsed.incomplete.trim().length === 0) { + return [{ + Complete: '', + Suggestion: '', + Description: parsed.field?.description || '', + Hint: parsed.field?.hint || '~channelname', + IconData: parsed.binding?.icon || '', + }]; + } + + return []; + } + + // getBooleanSuggestions returns true/false suggestions + getBooleanSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + const suggestions: AutocompleteSuggestion[] = []; + + if ('true'.startsWith(parsed.incomplete)) { + suggestions.push({ + Complete: 'true', + Suggestion: 'true', + Description: parsed.field?.description || '', + Hint: parsed.field?.hint || '', + IconData: parsed.binding?.icon || '', + }); + } + if ('false'.startsWith(parsed.incomplete)) { + suggestions.push({ + Complete: 'false', + Suggestion: 'false', + Description: parsed.field?.description || '', + Hint: parsed.field?.hint || '', + IconData: parsed.binding?.icon || '', + }); + } + return suggestions; + } +} + +function isMultiword(value: string) { + if (value.indexOf(' ') !== -1) { + return true; + } + + if (value.indexOf('\t') !== -1) { + return true; + } + + return false; +} 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 new file mode 100644 index 000000000..02a71206d --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export type { + AppCallRequest, + AppBinding, + AppField, + AppSelectOption, + AppCallResponse, + AppCallValues, + AppContext, + AppForm, + AutocompleteElement, + AutocompleteDynamicSelect, + AutocompleteStaticSelect, + AutocompleteUserSelect, + AutocompleteChannelSelect, +} from '@mm-redux/types/apps'; + +import type { + AutocompleteSuggestion, +} from '@mm-redux/types/integrations'; +export type {AutocompleteSuggestion}; + +export type { + Channel, +} from '@mm-redux/types/channels'; + +export type { + GlobalState, +} from '@mm-redux/types/store'; + +export type { + DispatchFunc, +} from '@mm-redux/types/actions'; + +export { + AppBindingLocations, + AppCallTypes, + AppFieldTypes, + AppCallResponseTypes, +} from '@mm-redux/constants/apps'; + +export {getAppsBindings} from '@mm-redux/selectors/entities/apps'; +export {getPost} from '@mm-redux/selectors/entities/posts'; +export {getChannel, getCurrentChannel, getChannelByName as selectChannelByName} from '@mm-redux/selectors/entities/channels'; +export {getCurrentTeamId, getCurrentTeam} from '@mm-redux/selectors/entities/teams'; +export {getUserByUsername as selectUserByUsername} from '@mm-redux/selectors/entities/users'; + +export {getUserByUsername} from '@mm-redux/actions/users'; +export {getChannelByNameAndTeamName} from '@mm-redux/actions/channels'; +export {sendEphemeralPost} from '@actions/views/post'; + +export {doAppCall} from '@actions/apps'; +export {createCallRequest} from '@utils/apps'; + +import Store from '@store/store'; +export const getStore = () => Store.redux; + +import keyMirror from '@mm-redux/utils/key_mirror'; +export {keyMirror}; + +export const EXECUTE_CURRENT_COMMAND_ITEM_ID = '_execute_current_command'; + +import type {ParsedCommand} from './app_command_parser'; +export const getExecuteSuggestion = (_: ParsedCommand): AutocompleteSuggestion | null => { // eslint-disable-line @typescript-eslint/no-unused-vars + return null; +}; + +import {Alert} from 'react-native'; +import {intlShape} from 'react-intl'; +export const displayError = (intl: typeof intlShape, body: string) => { + const title = intl.formatMessage({ + id: 'mobile.general.error.title', + defaultMessage: 'Error', + }); + Alert.alert(title, body); +}; 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 new file mode 100644 index 000000000..d372565f2 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_data.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + AppBinding, + AppForm, + AppFieldTypes, +} from './app_command_parser_test_dependencies'; + +export const reduxTestState = { + entities: { + channels: { + currentChannelId: 'current_channel_id', + myMembers: { + current_channel_id: { + channel_id: 'current_channel_id', + user_id: 'current_user_id', + roles: 'channel_role', + mention_count: 1, + msg_count: 9, + }, + }, + channels: { + current_channel_id: { + id: 'current_channel_id', + name: 'default-name', + display_name: 'Default', + delete_at: 0, + type: 'O', + total_msg_count: 10, + team_id: 'team_id', + }, + current_user_id__existingId: { + id: 'current_user_id__existingId', + name: 'current_user_id__existingId', + display_name: 'Default', + delete_at: 0, + type: '0', + total_msg_count: 0, + team_id: 'team_id', + }, + }, + channelsInTeam: { + 'team-id': ['current_channel_id'], + }, + }, + teams: { + currentTeamId: 'team-id', + teams: { + 'team-id': { + id: 'team_id', + name: 'team-1', + displayName: 'Team 1', + }, + }, + myMembers: { + 'team-id': {roles: 'team_role'}, + }, + }, + users: { + currentUserId: 'current_user_id', + profiles: { + current_user_id: {roles: 'system_role'}, + }, + }, + preferences: { + myPreferences: { + 'display_settings--name_format': { + category: 'display_settings', + name: 'name_format', + user_id: 'current_user_id', + value: 'username', + }, + }, + }, + roles: { + roles: { + system_role: { + permissions: [], + }, + team_role: { + permissions: [], + }, + channel_role: { + permissions: [], + }, + }, + }, + general: { + license: {IsLicensed: 'false'}, + serverVersion: '5.25.0', + config: {PostEditTimeLimit: -1}, + }, + }, +}; + +export const viewCommand: AppBinding = { + app_id: 'jira', + label: 'view', + location: 'view', + description: 'View details of a Jira issue', + form: { + call: { + path: '/view-issue', + }, + fields: [ + { + name: 'project', + label: 'project', + description: 'The Jira project description', + type: AppFieldTypes.DYNAMIC_SELECT, + hint: 'The Jira project hint', + is_required: true, + }, + { + name: 'issue', + position: 1, + description: 'The Jira issue key', + type: AppFieldTypes.TEXT, + hint: 'MM-11343', + is_required: true, + }, + ], + } as AppForm, +}; + +export const createCommand: AppBinding = { + app_id: 'jira', + label: 'create', + location: 'create', + description: 'Create a new Jira issue', + icon: 'Create icon', + hint: 'Create hint', + form: { + call: { + path: '/create-issue', + }, + fields: [ + { + name: 'project', + label: 'project', + description: 'The Jira project description', + type: AppFieldTypes.DYNAMIC_SELECT, + hint: 'The Jira project hint', + }, + { + name: 'summary', + label: 'summary', + description: 'The Jira issue summary', + type: AppFieldTypes.TEXT, + hint: 'The thing is working great!', + }, + { + name: 'verbose', + label: 'verbose', + description: 'display details', + type: AppFieldTypes.BOOL, + hint: 'yes or no!', + }, + { + name: 'epic', + label: 'epic', + description: 'The Jira epic', + type: AppFieldTypes.STATIC_SELECT, + hint: 'The thing is working great!', + options: [ + { + label: 'Dylan Epic', + value: 'epic1', + }, + { + label: 'Michael Epic', + value: 'epic2', + }, + ], + }, + ], + } as AppForm, +}; + +export const testBindings: AppBinding[] = [ + { + app_id: '', + label: '', + location: '/command', + bindings: [ + { + app_id: 'jira', + label: 'jira', + description: 'Interact with your Jira instance', + icon: 'Jira icon', + hint: 'Jira hint', + bindings: [{ + app_id: 'jira', + label: 'issue', + description: 'Interact with Jira issues', + icon: 'Issue icon', + hint: 'Issue hint', + bindings: [ + viewCommand, + createCommand, + ], + }], + }, + { + app_id: 'other', + label: 'other', + description: 'Other description', + icon: 'Other icon', + hint: 'Other hint', + bindings: [{ + app_id: 'other', + label: 'sub1', + description: 'Some Description', + form: { + fields: [{ + name: 'fieldname', + label: 'fieldlabel', + description: 'field description', + type: AppFieldTypes.TEXT, + hint: 'field hint', + }], + }, + }], + }, + ], + }, +]; diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_dependencies.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_dependencies.ts new file mode 100644 index 000000000..415f483f3 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_dependencies.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import thunk from 'redux-thunk'; +export {thunk}; + +const configureStore = require('redux-mock-store').default; +export {configureStore}; + +export {Client4} from '@mm-redux/client'; + +export type {AppBinding, AppForm} from '@mm-redux/types/apps'; +export {AppFieldTypes} from '@mm-redux/constants/apps'; + +export const checkForExecuteSuggestion = false; diff --git a/app/components/autocomplete/slash_suggestion/index.js b/app/components/autocomplete/slash_suggestion/index.ts similarity index 85% rename from app/components/autocomplete/slash_suggestion/index.js rename to app/components/autocomplete/slash_suggestion/index.ts index c7677b597..0dbd70454 100644 --- a/app/components/autocomplete/slash_suggestion/index.js +++ b/app/components/autocomplete/slash_suggestion/index.ts @@ -1,15 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {bindActionCreators} from 'redux'; +import {bindActionCreators, Dispatch} from 'redux'; import {connect} from 'react-redux'; import {createSelector} from 'reselect'; +import {GlobalState} from '@mm-redux/types/store'; import {getAutocompleteCommands, getCommandAutocompleteSuggestions} from '@mm-redux/actions/integrations'; import {getAutocompleteCommandsList, getCommandAutocompleteSuggestionsList} from '@mm-redux/selectors/entities/integrations'; import {getTheme} from '@mm-redux/selectors/entities/preferences'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {appsEnabled} from '@utils/apps'; + import SlashSuggestion from './slash_suggestion'; // TODO: Remove when all below commands have been implemented @@ -25,16 +28,17 @@ const mobileCommandsSelector = createSelector( }, ); -function mapStateToProps(state) { +function mapStateToProps(state: GlobalState) { return { commands: mobileCommandsSelector(state), currentTeamId: getCurrentTeamId(state), theme: getTheme(state), suggestions: getCommandAutocompleteSuggestionsList(state), + appsEnabled: appsEnabled(state), }; } -function mapDispatchToProps(dispatch) { +function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators({ getAutocompleteCommands, diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.test.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion.test.tsx new file mode 100644 index 000000000..43862e35e --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.test.tsx @@ -0,0 +1,263 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import Preferences from '@mm-redux/constants/preferences'; +import {Command, AutocompleteSuggestion} from '@mm-redux/types/integrations'; + +import Store from '@store/store'; + +import { + thunk, + configureStore, + Client4, + AppBinding, +} from './app_command_parser/tests/app_command_parser_test_dependencies'; + +import { + reduxTestState, + testBindings, +} from './app_command_parser/tests/app_command_parser_test_data'; + +const mockStore = configureStore([thunk]); + +const makeStore = async (bindings: AppBinding[]) => { + const initialState = { + ...reduxTestState, + entities: { + ...reduxTestState.entities, + apps: {bindings}, + }, + } as any; + const testStore = await mockStore(initialState); + + return testStore; +}; + +import SlashSuggestion, {Props} from './slash_suggestion'; + +describe('components/autocomplete/slash_suggestion', () => { + const sampleCommand = { + trigger: 'jitsi', + auto_complete: true, + auto_complete_desc: 'The Jitsi Description', + auto_complete_hint: 'The Jitsi Hint', + display_name: 'The Jitsi Display Name', + icon_url: 'Jitsi icon', + } as Command; + + const baseProps: Props = { + actions: { + getAutocompleteCommands: jest.fn(), + getCommandAutocompleteSuggestions: jest.fn(), + }, + currentTeamId: '', + commands: [sampleCommand], + isSearch: false, + maxListHeight: 50, + theme: Preferences.THEMES.default, + onChangeText: jest.fn(), + onResultCountChange: jest.fn(), + value: '', + nestedScrollEnabled: false, + suggestions: [], + rootId: '', + channelId: 'thechannel', + appsEnabled: true, + }; + + const f = Client4.getServerVersion; + + beforeAll(async () => { + Client4.getServerVersion = jest.fn().mockReturnValue('5.30.0'); + + const store = await makeStore(testBindings); + Store.redux = store; + }); + + afterAll(() => { + Client4.getServerVersion = f; + }); + + test('should match snapshot', () => { + const props: Props = { + ...baseProps, + }; + + const wrapper = shallow(); + + const dataSource: AutocompleteSuggestion[] = [ + { + Complete: 'thetrigger', + Description: 'The Description', + Hint: 'The Hint', + IconData: 'iconurl.com', + Suggestion: '/thetrigger', + }, + ]; + wrapper.setState({active: true, dataSource, lastCommandRequest: 1234}); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should show commands from props.commands', async () => { + const command = { + trigger: 'thetrigger', + auto_complete: true, + auto_complete_desc: 'The Description', + auto_complete_hint: 'The Hint', + display_name: 'The Display Name', + icon_url: 'iconurl.com', + } as Command; + + const props: Props = { + ...baseProps, + commands: [command], + }; + + const wrapper = shallow(); + wrapper.setProps({value: '/the'}); + + expect(wrapper.state('dataSource')).toEqual([ + { + Complete: 'thetrigger', + Description: 'The Description', + Hint: 'The Hint', + IconData: 'iconurl.com', + Suggestion: '/thetrigger', + }, + ]); + }); + + test('should show commands from app base commands', async () => { + const props: Props = { + ...baseProps, + commands: [], + }; + + const wrapper = shallow(); + wrapper.setProps({value: '/ji'}); + + expect(wrapper.state('dataSource')).toEqual([ + { + Complete: 'jira', + Description: 'Interact with your Jira instance', + Hint: 'Jira hint', + IconData: 'Jira icon', + Suggestion: '/jira', + }, + ]); + }); + + test('should show commands from app base commands and regular commands', async () => { + const props: Props = { + ...baseProps, + }; + + const wrapper = shallow(); + + wrapper.setProps({value: '/'}); + expect(wrapper.state('dataSource')).toEqual([ + { + Complete: 'jira', + Description: 'Interact with your Jira instance', + Hint: 'Jira hint', + IconData: 'Jira icon', + Suggestion: '/jira', + }, + { + Complete: 'jitsi', + Description: 'The Jitsi Description', + Hint: 'The Jitsi Hint', + IconData: 'Jitsi icon', + Suggestion: '/jitsi', + }, + { + Complete: 'other', + Description: 'Other description', + Hint: 'Other hint', + IconData: 'Other icon', + Suggestion: '/other', + }, + ]); + + wrapper.setProps({value: '/ji'}); + expect(wrapper.state('dataSource')).toEqual([ + { + Complete: 'jira', + Description: 'Interact with your Jira instance', + Hint: 'Jira hint', + IconData: 'Jira icon', + Suggestion: '/jira', + }, + { + Complete: 'jitsi', + Description: 'The Jitsi Description', + Hint: 'The Jitsi Hint', + IconData: 'Jitsi icon', + Suggestion: '/jitsi', + }, + ]); + }); + + test('should show commands from app sub commands', async (done) => { + const props: Props = { + ...baseProps, + }; + + const wrapper = shallow(); + wrapper.setProps({value: '/jira i', suggestions: []}); + + const expected: AutocompleteSuggestion[] = [ + { + Complete: 'jira issue', + Description: 'Interact with Jira issues', + Hint: 'Issue hint', + IconData: 'Issue icon', + Suggestion: 'issue', + }, + ]; + + setTimeout(() => { + expect(wrapper.state('dataSource')).toEqual(expected); + done(); + }); + }); + + test('should avoid using app commands when apps are disabled', async () => { + const props: Props = { + ...baseProps, + appsEnabled: false, + }; + + const wrapper = shallow(); + wrapper.setProps({value: '/', suggestions: []}); + + expect(wrapper.state('dataSource')).toEqual([ + { + Complete: 'jitsi', + Description: 'The Jitsi Description', + Hint: 'The Jitsi Hint', + IconData: 'Jitsi icon', + Suggestion: '/jitsi', + }, + ]); + + wrapper.setProps({value: '/ji', suggestions: []}); + + expect(wrapper.state('dataSource')).toEqual([ + { + Complete: 'jitsi', + Description: 'The Jitsi Description', + Hint: 'The Jitsi Hint', + IconData: 'Jitsi icon', + Suggestion: '/jitsi', + }, + ]); + + wrapper.setProps({value: '/jira i', suggestions: []}); + expect(wrapper.state('dataSource')).toEqual([]); + }); +}); diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.js b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx similarity index 55% rename from app/components/autocomplete/slash_suggestion/slash_suggestion.js rename to app/components/autocomplete/slash_suggestion/slash_suggestion.tsx index 6d7cb785e..a31714d12 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion.js +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx @@ -1,8 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {intlShape} from 'react-intl'; import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; import { FlatList, Platform, @@ -11,52 +11,73 @@ import { import {analytics} from '@init/analytics.ts'; import {Client4} from '@mm-redux/client'; import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; +import {Command, AutocompleteSuggestion, CommandArgs} from '@mm-redux/types/integrations'; +import {Theme} from '@mm-redux/types/preferences'; import {makeStyleSheetFromTheme} from '@utils/theme'; import SlashSuggestionItem from './slash_suggestion_item'; +import {AppCommandParser} from './app_command_parser/app_command_parser'; const TIME_BEFORE_NEXT_COMMAND_REQUEST = 1000 * 60 * 5; -export default class SlashSuggestion extends PureComponent { - static propTypes = { - actions: PropTypes.shape({ - getAutocompleteCommands: PropTypes.func.isRequired, - getCommandAutocompleteSuggestions: PropTypes.func.isRequired, - }).isRequired, - currentTeamId: PropTypes.string.isRequired, - commands: PropTypes.array, - isSearch: PropTypes.bool, - maxListHeight: PropTypes.number, - theme: PropTypes.object.isRequired, - onChangeText: PropTypes.func.isRequired, - onResultCountChange: PropTypes.func.isRequired, - value: PropTypes.string, - nestedScrollEnabled: PropTypes.bool, - suggestions: PropTypes.array, - rootId: PropTypes.string, - channelId: PropTypes.string, +export type Props = { + actions: { + getAutocompleteCommands: (channelID: string) => void; + getCommandAutocompleteSuggestions: (value: string, teamID: string, args: CommandArgs) => void; }; + currentTeamId: string; + commands: Command[]; + isSearch?: boolean; + maxListHeight?: number; + theme: Theme; + onChangeText: (text: string) => void; + onResultCountChange: (count: number) => void; + value: string; + nestedScrollEnabled?: boolean; + suggestions: AutocompleteSuggestion[]; + rootId?: string; + channelId: string; + appsEnabled: boolean; +}; +type State = { + active: boolean; + dataSource: AutocompleteSuggestion[]; + lastCommandRequest: number; +} + +export default class SlashSuggestion extends PureComponent { static defaultProps = { defaultChannel: {}, value: '', }; + static contextTypes = { + intl: intlShape.isRequired, + }; + + appCommandParser: AppCommandParser; + state = { active: false, dataSource: [], lastCommandRequest: 0, }; - setActive(active) { + constructor(props: Props, context: any) { + super(props); + this.appCommandParser = new AppCommandParser(null, context.intl, props.channelId, props.rootId); + } + + setActive(active: boolean) { this.setState({active}); } - setLastCommandRequest(lastCommandRequest) { + setLastCommandRequest(lastCommandRequest: number) { this.setState({lastCommandRequest}); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: Props) { if ((this.props.value === prevProps.value && this.props.suggestions === prevProps.suggestions && this.props.commands === prevProps.commands) || this.props.isSearch || this.props.value.startsWith('//') || !this.props.channelId) { return; @@ -88,25 +109,23 @@ export default class SlashSuggestion extends PureComponent { this.setLastCommandRequest(Date.now()); } - const matches = this.filterSlashSuggestions(nextValue.substring(1), nextCommands); - this.updateSuggestions(matches); + this.showBaseCommands(nextValue, nextCommands, prevProps.channelId, prevProps.rootId); } else if (isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) { - if (nextSuggestions === prevProps.suggestions) { + // If this is an app command, then hand it off to the app command parser. + if (this.props.appsEnabled && this.isAppCommand(nextValue, prevProps.channelId, prevProps.rootId)) { + this.fetchAndShowAppCommandSuggestions(nextValue, prevProps.channelId, prevProps.rootId); + } else if (nextSuggestions === prevProps.suggestions) { const args = { channel_id: prevProps.channelId, + team_id: prevProps.currentTeamId, ...(prevProps.rootId && {root_id: prevProps.rootId, parent_id: prevProps.rootId}), }; this.props.actions.getCommandAutocompleteSuggestions(nextValue, nextTeamId, args); } else { - const matches = []; - nextSuggestions.forEach((sug) => { - if (!this.contains(matches, '/' + sug.Complete)) { - matches.push({ - Complete: sug.Complete, - Suggestion: sug.Suggestion, - Hint: sug.Hint, - Description: sug.Description, - }); + const matches: AutocompleteSuggestion[] = []; + nextSuggestions.forEach((suggestion: AutocompleteSuggestion) => { + if (!this.contains(matches, '/' + suggestion.Complete)) { + matches.push(suggestion); } }); this.updateSuggestions(matches); @@ -116,15 +135,52 @@ export default class SlashSuggestion extends PureComponent { } } - updateSuggestions = (matches) => { + showBaseCommands = (text: string, commands: Command[], channelID: string, rootID?: string) => { + let matches: AutocompleteSuggestion[] = []; + + if (this.props.appsEnabled) { + const appCommands = this.getAppBaseCommandSuggestions(text, channelID, rootID); + matches = matches.concat(appCommands); + } + + matches = matches.concat(this.filterCommands(text.substring(1), commands)); + + matches.sort((match1, match2) => { + if (match1.Suggestion === match2.Suggestion) { + return 0; + } + return match1.Suggestion > match2.Suggestion ? 1 : -1; + }); + + this.updateSuggestions(matches); + } + + isAppCommand = (pretext: string, channelID: string, rootID?: string) => { + this.appCommandParser.setChannelContext(channelID, rootID); + return this.appCommandParser.isAppCommand(pretext); + } + + fetchAndShowAppCommandSuggestions = async (pretext: string, channelID: string, rootID?: string) => { + this.appCommandParser.setChannelContext(channelID, rootID); + const suggestions = await this.appCommandParser.getSuggestions(pretext); + this.updateSuggestions(suggestions); + } + + getAppBaseCommandSuggestions = (pretext: string, channelID: string, rootID?: string): AutocompleteSuggestion[] => { + this.appCommandParser.setChannelContext(channelID, rootID); + const suggestions = this.appCommandParser.getSuggestionsBase(pretext); + return suggestions; + } + + updateSuggestions = (matches: AutocompleteSuggestion[]) => { this.setState({ - active: matches.length, + active: Boolean(matches.length), dataSource: matches, }); this.props.onResultCountChange(matches.length); } - filterSlashSuggestions = (matchTerm, commands) => { + filterCommands = (matchTerm: string, commands: Command[]): AutocompleteSuggestion[] => { const data = commands.filter((command) => { if (!command.auto_complete) { return false; @@ -140,15 +196,16 @@ export default class SlashSuggestion extends PureComponent { Suggestion: '/' + item.trigger, Hint: item.auto_complete_hint, Description: item.auto_complete_desc, + IconData: item.icon_url, }; }); } - contains = (matches, complete) => { - return matches.findIndex((match) => match.complete === complete) !== -1; + contains = (matches: AutocompleteSuggestion[], complete: string): boolean => { + return matches.findIndex((match) => match.Complete === complete) !== -1; } - completeSuggestion = (command) => { + completeSuggestion = (command: string) => { const {onChangeText} = this.props; analytics.trackCommand('complete_suggestion', `/${command} `); @@ -176,9 +233,9 @@ export default class SlashSuggestion extends PureComponent { } }; - keyExtractor = (item) => item.id || item.Suggestion; + keyExtractor = (item: Command & AutocompleteSuggestion): string => item.id || item.Suggestion; - renderItem = ({item}) => ( + renderItem = ({item}: {item: AutocompleteSuggestion}) => ( ); } } -const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { return { listView: { flex: 1, diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.js b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx similarity index 83% rename from app/components/autocomplete/slash_suggestion/slash_suggestion_item.js rename to app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx index cd6e8bceb..11c0f4834 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.js +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx @@ -2,15 +2,16 @@ // See LICENSE.txt for license information. import React from 'react'; -import PropTypes from 'prop-types'; import {Image, Text, View} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import slashIcon from '@assets/images/autocomplete/slash_command.png'; +import {Theme} from '@mm-redux/types/preferences'; + import TouchableWithFeedback from '@components/touchable_with_feedback'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +const slashIcon = require('@assets/images/autocomplete/slash_command.png'); -const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { return { icon: { fontSize: 24, @@ -48,7 +49,16 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { }; }); -const SlashSuggestionItem = (props) => { +type Props = { + complete: string; + description: string; + hint: string; + onPress: (complete: string) => void; + suggestion: string; + theme: Theme; +} + +const SlashSuggestionItem = (props: Props) => { const insets = useSafeAreaInsets(); const { complete, @@ -70,6 +80,14 @@ const SlashSuggestionItem = (props) => { suggestionText = suggestionText.substring(1); } + if (hint) { + if (suggestionText.length) { + suggestionText += ` ${hint}`; + } else { + suggestionText = hint; + } + } + return ( { /> - {`${suggestionText} ${hint}`} + {`${suggestionText}`} { ); }; -SlashSuggestionItem.propTypes = { - description: PropTypes.string, - hint: PropTypes.string, - onPress: PropTypes.func.isRequired, - theme: PropTypes.object.isRequired, - suggestion: PropTypes.string, - complete: PropTypes.string, -}; - export default SlashSuggestionItem; diff --git a/app/components/embedded_bindings/button_binding/button_binding.tsx b/app/components/embedded_bindings/button_binding/button_binding.tsx new file mode 100644 index 000000000..de606313e --- /dev/null +++ b/app/components/embedded_bindings/button_binding/button_binding.tsx @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import Button from 'react-native-button'; +import {intlShape} from 'react-intl'; + +import {preventDoubleTap} from 'app/utils/tap'; +import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; +import {getStatusColors} from '@utils/message_attachment_colors'; +import ButtonBindingText from './button_binding_text'; +import {Theme} from '@mm-redux/types/preferences'; +import {ActionResult} from '@mm-redux/types/actions'; +import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps'; +import {Post} from '@mm-redux/types/posts'; +import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps'; +import {createCallContext, createCallRequest} from '@utils/apps'; +import {Channel} from '@mm-redux/types/channels'; + +type Props = { + actions: { + doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>; + getChannel: (channelId: string) => Promise; + sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise; + }; + post: Post; + binding: AppBinding; + theme: Theme; + currentTeamID: string; +} +export default class ButtonBinding extends PureComponent { + static contextTypes = { + intl: intlShape.isRequired, + }; + handleActionPress = preventDoubleTap(async () => { + const ephemeral = (message: string) => this.props.actions.sendEphemeralPost(message, this.props.post.channel_id, this.props.post.root_id); + + const { + binding, + post, + currentTeamID, + } = this.props; + const intl = this.context.intl; + if (!binding.call) { + return; + } + + let teamID = ''; + const {data} = await this.props.actions.getChannel(post.channel_id) as {data?: any; error?: any}; + if (data) { + const channel = data as Channel; + teamID = channel.team_id; + } + + const context = createCallContext( + binding.app_id, + AppBindingLocations.IN_POST + binding.location, + post.channel_id, + teamID || currentTeamID, + post.id, + ); + const call = createCallRequest( + binding.call, + context, + {post: AppExpandLevels.EXPAND_ALL}, + ); + this.setState({executing: true}); + const res = await this.props.actions.doAppCall(call, AppCallTypes.SUBMIT, this.context.intl); + this.setState({executing: false}); + + if (res.error) { + const errorResponse = res.error; + const errorMessage = errorResponse.error || intl.formatMessage( + {id: 'apps.error.unknown', + defaultMessage: 'Unknown error occurred.', + }); + ephemeral(errorMessage); + return; + } + + const callResp = res.data!; + + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.markdown) { + ephemeral(callResp.markdown); + } + return; + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.FORM: + return; + default: { + const errorMessage = intl.formatMessage( + { + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, + { + type: callResp.type, + }, + ); + ephemeral(errorMessage); + } + } + }, 4000); + + render() { + const {theme, binding} = this.props; + const style = getStyleSheet(theme); + + return ( + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + const STATUS_COLORS = getStatusColors(theme); + return { + button: { + borderRadius: 4, + borderColor: changeOpacity(STATUS_COLORS.default, 0.25), + borderWidth: 2, + opacity: 1, + alignItems: 'center', + marginTop: 12, + justifyContent: 'center', + height: 36, + }, + buttonDisabled: { + backgroundColor: changeOpacity(theme.buttonBg, 0.3), + }, + text: { + color: STATUS_COLORS.default, + fontSize: 15, + fontWeight: '600', + lineHeight: 17, + }, + }; +}); diff --git a/app/components/embedded_bindings/button_binding/button_binding_text.test.tsx b/app/components/embedded_bindings/button_binding/button_binding_text.test.tsx new file mode 100644 index 000000000..f05cab7c3 --- /dev/null +++ b/app/components/embedded_bindings/button_binding/button_binding_text.test.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; +import ButtonBindingText from './button_binding_text'; + +describe('ButtonBinding emojis', () => { + const emojis = [ + { + name: 'smile', + literal: ':smile:', + }, + { + name: 'custom_emoji', + literal: ':custom_emoji:', + }, + { + name: 'heart', + literal: ':heart:', + }, + { + name: 'one', + literal: ':one:', + }, + { + name: 'slightly_smiling_face', + literal: ':)', + }, + { + name: 'wink', + literal: ';)', + }, + { + name: 'open_mouth', + literal: ':o', + }, + { + name: 'scream', + literal: ':-o', + }, + { + name: 'smirk', + literal: ':]', + }, + { + name: 'smile', + literal: ':D', + }, + { + name: 'stuck_out_tongue_closed_eyes', + literal: 'x-d', + }, + { + name: 'stuck_out_tongue', + literal: ':p', + }, + { + name: 'rage', + literal: ':@', + }, + { + name: 'slightly_frowning_face', + literal: ':(', + }, + { + name: 'cry', + literal: ':`(', + }, + { + name: 'confused', + literal: ':/', + }, + { + name: 'confounded', + literal: ':s', + }, + { + name: 'neutral_face', + literal: ':|', + }, + { + name: 'flushed', + literal: ':$', + }, + { + name: 'mask', + literal: ':-x', + }, + { + name: 'heart', + literal: '<3', + }, + { + name: 'broken_heart', + literal: ' { + test('only emoji ' + name, () => { + const baseProps = {message: literal, style: {fontSize: 12}}; + + const wrapper = shallow(); + + expect(wrapper.getElement().props.children.length).toBe(1); + + const child = wrapper.getElement().props.children[0]; + + expect(child.type.displayName).toBe('Connect(Emoji)'); + expect(child.props.emojiName).toBe(name); + expect(child.props.literal).toBe(literal); + expect(child.props.textStyle.fontSize).toBe(12); + }); + + test('emoji ' + name + ' with additional text', () => { + const baseProps = {message: 'emoji test with literal equals to ' + literal, style: {fontSize: 12}}; + + const wrapper = shallow(); + + expect(wrapper.getElement().props.children.length).toBe(2); + + const textChild = wrapper.getElement().props.children[0]; + const emoticonChild = wrapper.getElement().props.children[1]; + + expect(textChild.type.displayName).toBe('Text'); + expect(textChild.props.children).toBe('emoji test with literal equals to '); + expect(textChild.props.style.fontSize).toBe(12); + expect(emoticonChild.props.emojiName).toBe(name); + expect(emoticonChild.props.literal).toBe(literal); + expect(emoticonChild.type.displayName).toBe('Connect(Emoji)'); + expect(emoticonChild.props.textStyle.fontSize).toBe(12); + }); + }); +}); diff --git a/app/components/embedded_bindings/button_binding/button_binding_text.tsx b/app/components/embedded_bindings/button_binding/button_binding_text.tsx new file mode 100644 index 000000000..0044ff74f --- /dev/null +++ b/app/components/embedded_bindings/button_binding/button_binding_text.tsx @@ -0,0 +1,85 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import PropTypes from 'prop-types'; +import {Text, View, StyleSheet, StyleProp, TextStyle} from 'react-native'; +import Emoji from 'app/components/emoji'; +import {getEmoticonName} from 'app/utils/emoji_utils'; +import {reEmoji, reEmoticon, reMain} from 'app/constants/emoji'; + +export default function ButtonBindingText({message, style}: {message: string; style: StyleProp}) { + const components = [] as JSX.Element[]; + + let text = message; + while (text) { + let match; + + // See if the text starts with an emoji + if ((match = text.match(reEmoji))) { + components.push( + , + ); + text = text.substring(match[0].length); + continue; + } + + // Or an emoticon + if ((match = text.match(reEmoticon))) { + const emoticonName = getEmoticonName(match[0]); + if (emoticonName) { + components.push( + , + ); + text = text.substring(match[0].length); + continue; + } + } + + // This is plain text, so capture as much text as possible until we hit the next possible emoji. Note that + // reMain always captures at least one character, so text will always be getting shorter + match = text.match(reMain); + if (!match) { + continue; + } + + components.push( + + {match[0]} + , + ); + text = text.substring(match[0].length); + } + + return ( + + {components} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'flex-start', + flexDirection: 'row', + flexWrap: 'wrap', + }, +}); + +ButtonBindingText.propTypes = { + message: PropTypes.string.isRequired, + style: PropTypes.object.isRequired, +}; diff --git a/app/components/embedded_bindings/button_binding/index.ts b/app/components/embedded_bindings/button_binding/index.ts new file mode 100644 index 000000000..3bda0affe --- /dev/null +++ b/app/components/embedded_bindings/button_binding/index.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; +import {connect} from 'react-redux'; + +import {getTheme} from '@mm-redux/selectors/entities/preferences'; + +import {GlobalState} from '@mm-redux/types/store'; +import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions'; +import {AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps'; +import {doAppCall} from '@actions/apps'; +import {getPost} from '@mm-redux/selectors/entities/posts'; + +import ButtonBinding from './button_binding'; +import {getChannel} from '@mm-redux/actions/channels'; +import {sendEphemeralPost} from '@actions/views/post'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; + +type OwnProps = { + postId: string; +} + +function mapStateToProps(state: GlobalState, ownProps: OwnProps) { + return { + theme: getTheme(state), + post: getPost(state, ownProps.postId), + currentTeamID: getCurrentTeamId(state), + }; +} + +type Actions = { + doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>; + getChannel: (channelId: string) => Promise; + sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators, Actions>({ + doAppCall, + getChannel, + sendEphemeralPost, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ButtonBinding); diff --git a/app/components/embedded_bindings/embed_text.tsx b/app/components/embedded_bindings/embed_text.tsx new file mode 100644 index 000000000..20076ed24 --- /dev/null +++ b/app/components/embedded_bindings/embed_text.tsx @@ -0,0 +1,115 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import {LayoutChangeEvent, ScrollView, StyleProp, TextStyle, View, ViewStyle} from 'react-native'; + +import Markdown from 'app/components/markdown'; +import ShowMoreButton from 'app/components/show_more_button'; +import {Theme} from '@mm-redux/types/preferences'; + +const SHOW_MORE_HEIGHT = 60; + +type Props = { + baseTextStyle: StyleProp, + blockStyles?: StyleProp[], + deviceHeight: number, + onPermalinkPress?: () => void, + textStyles?: StyleProp[], + theme?: Theme, + value?: string, +} + +type State = { + collapsed: boolean; + isLongText: boolean; + maxHeight?: number; +} + +export default class EmbedText extends PureComponent { + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + const {deviceHeight} = nextProps; + const maxHeight = Math.round((deviceHeight * 0.4) + SHOW_MORE_HEIGHT); + + if (maxHeight !== prevState.maxHeight) { + return { + maxHeight, + }; + } + + return null; + } + + constructor(props: Props) { + super(props); + + this.state = { + collapsed: true, + isLongText: false, + }; + } + + handleLayout = (event: LayoutChangeEvent) => { + const {height} = event.nativeEvent.layout; + const {maxHeight} = this.state; + + if (height >= (maxHeight || 0)) { + this.setState({ + isLongText: true, + }); + } + }; + + toggleCollapseState = () => { + const {collapsed} = this.state; + this.setState({collapsed: !collapsed}); + }; + + render() { + const { + baseTextStyle, + blockStyles, + onPermalinkPress, + textStyles, + theme, + value, + } = this.props; + const {collapsed, isLongText, maxHeight} = this.state; + + if (!value) { + return null; + } + + return ( + + + + + + + {isLongText && + + } + + ); + } +} diff --git a/app/components/embedded_bindings/embed_title.tsx b/app/components/embedded_bindings/embed_title.tsx new file mode 100644 index 000000000..d2aeb84a1 --- /dev/null +++ b/app/components/embedded_bindings/embed_title.tsx @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import {View} from 'react-native'; + +import Markdown from '@components/markdown'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {Theme} from '@mm-redux/types/preferences'; + +type Props = { + theme: Theme; + value?: string; +} +export default class EmbedTitle extends PureComponent { + render() { + const { + value, + theme, + } = this.props; + + if (!value) { + return null; + } + + const style = getStyleSheet(theme); + + const title = ( + + ); + + return ( + + {title} + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + marginTop: 3, + flex: 1, + flexDirection: 'row', + }, + title: { + color: theme.centerChannelColor, + fontWeight: '600', + marginBottom: 5, + fontSize: 14, + lineHeight: 20, + }, + link: { + color: theme.linkColor, + }, + }; +}); diff --git a/app/components/embedded_bindings/embedded_binding.tsx b/app/components/embedded_bindings/embedded_binding.tsx new file mode 100644 index 000000000..f6f8aa30c --- /dev/null +++ b/app/components/embedded_bindings/embedded_binding.tsx @@ -0,0 +1,85 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +import EmbedSubBindings from './embedded_sub_bindings'; +import EmbedText from './embed_text'; +import EmbedTitle from './embed_title'; +import {Theme} from '@mm-redux/types/preferences'; +import {AppBinding} from '@mm-redux/types/apps'; +import {copyAndFillBindings} from '@utils/apps'; + +type Props = { + embed: AppBinding, + baseTextStyle?: StyleProp, + blockStyles?: StyleProp[], + deviceHeight: number, + postId: string, + onPermalinkPress?: () => void, + theme: Theme, + textStyles?: StyleProp[], +} + +export default function EmbeddedBinding(props: Props) { + const { + embed, + baseTextStyle, + blockStyles, + deviceHeight, + onPermalinkPress, + postId, + textStyles, + theme, + } = props; + + const style = getStyleSheet(theme); + + const bindings = copyAndFillBindings(embed)?.bindings; + + return ( + <> + + + + + + + ); +} + +const getStyleSheet = makeStyleSheetFromTheme((theme:Theme) => { + return { + container: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.15), + borderRightColor: changeOpacity(theme.centerChannelColor, 0.15), + borderTopColor: changeOpacity(theme.centerChannelColor, 0.15), + borderBottomWidth: 1, + borderRightWidth: 1, + borderTopWidth: 1, + marginTop: 5, + padding: 12, + }, + border: { + borderLeftColor: changeOpacity(theme.linkColor, 0.6), + borderLeftWidth: 3, + }, + }; +}); diff --git a/app/components/embedded_bindings/embedded_sub_bindings.tsx b/app/components/embedded_bindings/embedded_sub_bindings.tsx new file mode 100644 index 000000000..85805ce01 --- /dev/null +++ b/app/components/embedded_bindings/embedded_sub_bindings.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import BindingMenu from './menu_binding'; +import ButtonBinding from './button_binding'; +import {AppBinding} from '@mm-redux/types/apps'; + +type Props = { + bindings?: AppBinding[]; + postId: string; +} +export default function EmbeddedSubBindings(props: Props) { + const { + bindings, + postId, + } = props; + + if (!bindings?.length) { + return null; + } + + const content = [] as JSX.Element[]; + + bindings.forEach((binding) => { + if (!binding.app_id || !binding.call) { + return; + } + + if ((binding.bindings?.length || 0) > 0) { + content.push( + , + ); + return; + } + + content.push( + , + ); + }); + + return content.length ? (<>{content}) : null; +} diff --git a/app/components/embedded_bindings/index.tsx b/app/components/embedded_bindings/index.tsx new file mode 100644 index 000000000..e5c9963fb --- /dev/null +++ b/app/components/embedded_bindings/index.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'; + +import EmbeddedBinding from './embedded_binding'; +import {Theme} from '@mm-redux/types/preferences'; +import {AppBinding} from '@mm-redux/types/apps'; + +type Props = { + embeds: AppBinding[], + baseTextStyle?: StyleProp, + blockStyles?: StyleProp[], + deviceHeight: number, + deviceWidth: number, + postId: string, + onPermalinkPress?: () => void, + theme: Theme, + textStyles?: StyleProp[], +} + +export default function EmbeddedBindings(props: Props) { + const { + embeds, + baseTextStyle, + blockStyles, + deviceHeight, + onPermalinkPress, + postId, + theme, + textStyles, + } = props; + const content = [] as JSX.Element[]; + + embeds.forEach((embed, i) => { + content.push( + , + ); + }); + + return ( + + {content} + + ); +} diff --git a/app/components/embedded_bindings/menu_binding/index.ts b/app/components/embedded_bindings/menu_binding/index.ts new file mode 100644 index 000000000..58d683901 --- /dev/null +++ b/app/components/embedded_bindings/menu_binding/index.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; +import {connect} from 'react-redux'; + +import {GlobalState} from '@mm-redux/types/store'; +import {doAppCall} from '@actions/apps'; +import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions'; +import {AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps'; +import {getPost} from '@mm-redux/selectors/entities/posts'; + +import MenuBinding from './menu_binding'; +import {getChannel} from '@mm-redux/actions/channels'; +import {sendEphemeralPost} from '@actions/views/post'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; + +type OwnProps = { + postId: string; +} + +function mapStateToProps(state: GlobalState, ownProps: OwnProps) { + return { + post: getPost(state, ownProps.postId), + currentTeamID: getCurrentTeamId(state), + }; +} + +type Actions = { + doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>; + getChannel: (channelId: string) => Promise; + sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators, Actions>({ + doAppCall, + getChannel, + sendEphemeralPost, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(MenuBinding); diff --git a/app/components/embedded_bindings/menu_binding/menu_binding.tsx b/app/components/embedded_bindings/menu_binding/menu_binding.tsx new file mode 100644 index 000000000..1d0394385 --- /dev/null +++ b/app/components/embedded_bindings/menu_binding/menu_binding.tsx @@ -0,0 +1,137 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; + +import AutocompleteSelector from 'app/components/autocomplete_selector'; +import {intlShape} from 'react-intl'; +import {PostActionOption} from '@mm-redux/types/integration_actions'; +import {Post} from '@mm-redux/types/posts'; +import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps'; +import {ActionResult} from '@mm-redux/types/actions'; +import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps'; +import {Channel} from '@mm-redux/types/channels'; +import {createCallContext, createCallRequest} from '@utils/apps'; + +type Props = { + actions: { + doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>; + getChannel: (channelId: string) => Promise; + sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise; + }; + binding?: AppBinding; + post: Post; + currentTeamID: string; +} + +type State = { + selected?: PostActionOption; +} + +export default class MenuBinding extends PureComponent { + static contextTypes = { + intl: intlShape.isRequired, + }; + + constructor(props: Props) { + super(props); + this.state = {}; + } + + handleSelect = async (selected?: PostActionOption) => { + if (!selected) { + return; + } + const ephemeral = (message: string) => this.props.actions.sendEphemeralPost(message, this.props.post.channel_id, this.props.post.root_id); + + this.setState({selected}); + const binding = this.props.binding?.bindings?.find((b) => b.location === selected.value); + if (!binding) { + console.debug('Trying to select element not present in binding.'); //eslint-disable-line no-console + return; + } + + if (!binding.call) { + return; + } + + const { + actions, + post, + currentTeamID, + } = this.props; + const intl = this.context.intl; + + let teamID = ''; + const {data} = await this.props.actions.getChannel(post.channel_id) as {data?: any; error?: any}; + if (data) { + const channel = data as Channel; + teamID = channel.team_id; + } + + const context = createCallContext( + binding.app_id, + AppBindingLocations.IN_POST + binding.location, + post.channel_id, + teamID || currentTeamID, + post.id, + ); + const call = createCallRequest( + binding.call, + context, + {post: AppExpandLevels.EXPAND_ALL}, + ); + + const res = await actions.doAppCall(call, AppCallTypes.SUBMIT, this.context.intl); + if (res.error) { + const errorResponse = res.error; + const errorMessage = errorResponse.error || intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error occurred.', + }); + ephemeral(errorMessage); + return; + } + + const callResp = res.data!; + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.markdown) { + ephemeral(callResp.markdown); + } + return; + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.FORM: + return; + default: { + const errorMessage = intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResp.type, + }); + ephemeral(errorMessage); + } + } + }; + + render() { + const { + binding, + } = this.props; + const {selected} = this.state; + + const options = binding?.bindings?.map((b:AppBinding) => { + return {text: b.label, value: b.location || ''}; + }); + + return ( + + ); + } +} diff --git a/app/components/message_attachments/action_button/action_button_text.js b/app/components/message_attachments/action_button/action_button_text.js index 4f6e453d0..c947b8c83 100644 --- a/app/components/message_attachments/action_button/action_button_text.js +++ b/app/components/message_attachments/action_button/action_button_text.js @@ -6,16 +6,7 @@ import PropTypes from 'prop-types'; import {Text, View, StyleSheet} from 'react-native'; import Emoji from 'app/components/emoji'; import {getEmoticonName} from 'app/utils/emoji_utils'; - -// reEmoji matches an emoji (eg. :taco:) at the start of a string. -const reEmoji = /^:([a-z0-9_\-+]+):\B/i; - -// reEmoticon matches an emoticon (eg. :D) at the start of a string. -const reEmoticon = /^(?:(:-?\))|(;-?\))|(:o)|(:-o)|(:-?])|(:-?d)|(x-d)|(:-?p)|(:-?[[@])|(:-?\()|(:['’]-?\()|(:-?\/)|(:-?s)|(:-?\|)|(:-?\$)|(:-x)|(<3|<3)|(<\/3|<\/3)|(:[`'’]-?\(|:'\(|:'\())(?=$|\s|[*_~?])/i; - -// reMain matches some amount of plain text, starting at the beginning of the string and hopefully stopping right -// before the next emoji by looking for any character that could start an emoji (:, ;, x, or <) -const reMain = /^[\s\S]+?(?=[:;x<]|$)/i; +import {reEmoji, reEmoticon, reMain} from 'app/constants/emoji'; export default function ActionButtonText({message, style}) { const components = []; diff --git a/app/components/post_body/index.js b/app/components/post_body/index.js index ab8371972..4e8fbe6bc 100644 --- a/app/components/post_body/index.js +++ b/app/components/post_body/index.js @@ -27,6 +27,7 @@ import {getDimensions} from 'app/selectors/device'; import {hasEmojisOnly} from 'app/utils/emoji_utils'; import PostBody from './post_body'; +import {appsEnabled} from '@utils/apps'; const POST_TIMEOUT = 20000; @@ -112,6 +113,7 @@ export function makeMapStateToProps() { theme: getTheme(state), mentionKeys: getMentionKeysForPost(state, channel, postProps?.disable_group_highlight, postProps?.mentionHighlightDisabled), canDelete, + appsEnabled: appsEnabled(state), ...getDimensions(state), }; }; diff --git a/app/components/post_body/post_body.js b/app/components/post_body/post_body.js index 44b5c57f7..6d7db4ce7 100644 --- a/app/components/post_body/post_body.js +++ b/app/components/post_body/post_body.js @@ -73,6 +73,7 @@ export default class PostBody extends PureComponent { theme: PropTypes.object, location: PropTypes.string, mentionKeys: PropTypes.array, + appsEnabled: PropTypes.bool.isRequired, }; static defaultProps = { @@ -271,13 +272,14 @@ export default class PostBody extends PureComponent { onPermalinkPress, post, postProps, + appsEnabled, } = this.props; if (isSystemMessage && !isPostEphemeral) { return null; } - if (!metadata?.embeds?.length) { + if (!metadata?.embeds?.length && !(appsEnabled && postProps.app_bindings)) { return null; } diff --git a/app/components/post_body/post_body.test.js b/app/components/post_body/post_body.test.js index cc2a33680..f86a73d37 100644 --- a/app/components/post_body/post_body.test.js +++ b/app/components/post_body/post_body.test.js @@ -46,6 +46,7 @@ describe('PostBody', () => { isEmojiOnly: false, shouldRenderJumboEmoji: false, theme: Preferences.THEMES.default, + appsEnabled: false, }; test('should mount additional content for non-system messages', () => { diff --git a/app/components/post_body_additional_content/index.js b/app/components/post_body_additional_content/index.js index f41975bc1..6073b8523 100644 --- a/app/components/post_body_additional_content/index.js +++ b/app/components/post_body_additional_content/index.js @@ -14,6 +14,7 @@ import {ViewTypes} from 'app/constants'; import {getDimensions} from 'app/selectors/device'; import PostBodyAdditionalContent from './post_body_additional_content'; +import {appsEnabled} from '@utils/apps'; function selectOpenGraphData(metadata, url) { if (!metadata || !metadata.embeds) { @@ -27,7 +28,7 @@ function selectOpenGraphData(metadata, url) { function mapStateToProps(state, ownProps) { const config = getConfig(state); - const link = ownProps.metadata.embeds[0]?.url || ''; + const link = ownProps.metadata?.embeds?.[0]?.url || ''; let expandedLink; if (link) { expandedLink = selectExpandedLink(state, link); @@ -54,6 +55,7 @@ function mapStateToProps(state, ownProps) { openGraphData, showLinkPreviews: previewsEnabled && config.EnableLinkPreviews === 'true' && !removeLinkPreview, theme: getTheme(state), + appsEnabled: appsEnabled(state), }; } diff --git a/app/components/post_body_additional_content/post_body_additional_content.js b/app/components/post_body_additional_content/post_body_additional_content.js index fa69996a6..6ef52ed4d 100644 --- a/app/components/post_body_additional_content/post_body_additional_content.js +++ b/app/components/post_body_additional_content/post_body_additional_content.js @@ -23,6 +23,7 @@ import EventEmitter from '@mm-redux/utils/event_emitter'; import {generateId} from '@utils/file'; import {calculateDimensions, getViewPortWidth, openGalleryAtIndex} from '@utils/images'; import {getYouTubeVideoId, isImageLink, isYoutubeLink, tryOpenURL} from '@utils/url'; +import EmbeddedBindings from '@components/embedded_bindings'; const MAX_YOUTUBE_IMAGE_HEIGHT = 202; const MAX_YOUTUBE_IMAGE_WIDTH = 360; @@ -53,6 +54,7 @@ export default class PostBodyAdditionalContent extends ImageViewPort { showLinkPreviews: PropTypes.bool.isRequired, theme: PropTypes.object.isRequired, textStyles: PropTypes.object, + appsEnabled: PropTypes.bool.isRequired, }; static contextTypes = { @@ -377,8 +379,41 @@ export default class PostBodyAdditionalContent extends ImageViewPort { return null; }; + renderAppEmbeds = () => { + const { + postId, + postProps, + baseTextStyle, + blockStyles, + deviceHeight, + deviceWidth, + onPermalinkPress, + textStyles, + theme, + } = this.props; + const {app_bindings} = postProps; + + if (app_bindings && app_bindings.length) { + return ( + + ); + } + + return null; + } + renderOpenGraph = (isYouTube, isImage) => { - const {isReplyPost, link, metadata, openGraphData, postId, showLinkPreviews, theme} = this.props; + const {isReplyPost, link, metadata, openGraphData, postId, showLinkPreviews, theme, appsEnabled} = this.props; if (isYouTube || (isImage && !openGraphData)) { return null; @@ -389,6 +424,13 @@ export default class PostBodyAdditionalContent extends ImageViewPort { return attachments; } + if (appsEnabled) { + const appEmbeds = this.renderAppEmbeds(); + if (appEmbeds) { + return appEmbeds; + } + } + if (!openGraphData || !showLinkPreviews) { return null; } @@ -495,9 +537,10 @@ export default class PostBodyAdditionalContent extends ImageViewPort { if (expandedLink) { link = expandedLink; } - const {attachments} = postProps; - if (!link && !attachments) { + const {attachments, app_bindings, appsEnabled} = postProps; + + if (!link && !attachments && !(appsEnabled && app_bindings)) { return null; } diff --git a/app/components/post_body_additional_content/post_body_additional_content.test.js b/app/components/post_body_additional_content/post_body_additional_content.test.js index ee051854e..541af38b6 100644 --- a/app/components/post_body_additional_content/post_body_additional_content.test.js +++ b/app/components/post_body_additional_content/post_body_additional_content.test.js @@ -23,6 +23,7 @@ describe('PostBodyAdditionalContent', () => { postProps: {}, showLinkPreviews: false, theme: Preferences.THEMES.default, + appsEnabled: false, }; test('should call getRedirectLocation only if expandedLink has not been set', () => { diff --git a/app/components/post_draft/draft_input/draft_input.js b/app/components/post_draft/draft_input/draft_input.js index 06edf5850..1feaf9350 100644 --- a/app/components/post_draft/draft_input/draft_input.js +++ b/app/components/post_draft/draft_input/draft_input.js @@ -303,7 +303,7 @@ export default class DraftInput extends PureComponent { return; } - const {data, error} = await executeCommand(msg, channelId, rootId); + const {data, error} = await executeCommand(msg, channelId, rootId, intl); this.setState({sendingMessage: false}); if (error) { diff --git a/app/components/widgets/settings/text_setting.js b/app/components/widgets/settings/text_setting.js index d1f1f7b47..30170c5a1 100644 --- a/app/components/widgets/settings/text_setting.js +++ b/app/components/widgets/settings/text_setting.js @@ -18,6 +18,8 @@ import { } from '@utils/theme'; export default class TextSetting extends PureComponent { + static validTypes = ['input', 'textarea', 'number', 'email', 'tel', 'url', 'password']; + static propTypes = { id: PropTypes.string.isRequired, label: PropTypes.oneOfType([ diff --git a/app/constants/emoji.js b/app/constants/emoji.js index f3e5213fb..73c4ee457 100644 --- a/app/constants/emoji.js +++ b/app/constants/emoji.js @@ -3,3 +3,13 @@ export const ALL_EMOJIS = 'all_emojis'; export const MAX_ALLOWED_REACTIONS = 40; + +// reEmoji matches an emoji (eg. :taco:) at the start of a string. +export const reEmoji = /^:([a-z0-9_\-+]+):\B/i; + +// reEmoticon matches an emoticon (eg. :D) at the start of a string. +export const reEmoticon = /^(?:(:-?\))|(;-?\))|(:o)|(:-o)|(:-?])|(:-?d)|(x-d)|(:-?p)|(:-?[[@])|(:-?\()|(:['’]-?\()|(:-?\/)|(:-?s)|(:-?\|)|(:-?\$)|(:-x)|(<3|<3)|(<\/3|<\/3)|(:[`'’]-?\(|:'\(|:'\())(?=$|\s|[*_~?])/i; + +// reMain matches some amount of plain text, starting at the beginning of the string and hopefully stopping right +// before the next emoji by looking for any character that could start an emoji (:, ;, x, or <) +export const reMain = /^[\s\S]+?(?=[:;x<]|$)/i; diff --git a/app/constants/screen.js b/app/constants/screen.js index 8c8a0ec9d..6ce07b743 100644 --- a/app/constants/screen.js +++ b/app/constants/screen.js @@ -2,3 +2,6 @@ // See LICENSE.txt for license information. export const THREAD = 'thread'; +export const PERMALINK = 'permalink'; +export const SEARCH = 'search'; +export const CHANNEL = 'channel'; diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index bc84f88d9..45002b88d 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -43,5 +43,6 @@ const WebsocketEvents = { INCREASE_POST_VISIBILITY_BY_ONE: 'increase_post_visibility_by_one', MEMBERROLE_UPDATED: 'memberrole_updated', RECEIVED_GROUP: 'received_group', + APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings', }; export default WebsocketEvents; diff --git a/app/mm-redux/action_types/apps.ts b/app/mm-redux/action_types/apps.ts new file mode 100644 index 000000000..196c4f602 --- /dev/null +++ b/app/mm-redux/action_types/apps.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import keyMirror from '@mm-redux/utils/key_mirror'; + +export default keyMirror({ + RECEIVED_APP_BINDINGS: null, +}); diff --git a/app/mm-redux/action_types/index.ts b/app/mm-redux/action_types/index.ts index 305bf3a1e..dba4c1d29 100644 --- a/app/mm-redux/action_types/index.ts +++ b/app/mm-redux/action_types/index.ts @@ -20,6 +20,7 @@ import GroupTypes from './groups'; import BotTypes from './bots'; import PluginTypes from './plugins'; import ChannelCategoryTypes from './channel_categories'; +import AppsTypes from './apps'; export { ErrorTypes, @@ -41,4 +42,5 @@ export { BotTypes, PluginTypes, ChannelCategoryTypes, + AppsTypes, }; diff --git a/app/mm-redux/actions/apps.ts b/app/mm-redux/actions/apps.ts new file mode 100644 index 000000000..3b61c2279 --- /dev/null +++ b/app/mm-redux/actions/apps.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {AppsTypes} from '@mm-redux/action_types'; +import {Client4} from '@mm-redux/client'; + +import {ActionFunc} from '@mm-redux/types/actions'; + +import {bindClientFunc} from './helpers'; + +export function fetchAppBindings(userID: string, channelID: string): ActionFunc { + return bindClientFunc({ + clientFunc: () => Client4.getAppsBindings(userID, channelID), + onSuccess: AppsTypes.RECEIVED_APP_BINDINGS, + }); +} diff --git a/app/mm-redux/actions/helpers.ts b/app/mm-redux/actions/helpers.ts index a261618a0..2f173907b 100644 --- a/app/mm-redux/actions/helpers.ts +++ b/app/mm-redux/actions/helpers.ts @@ -111,7 +111,7 @@ export function bindClientFunc({ // Debounce function based on underscores modified to use es6 and a cb -export function debounce(func: (...args: any) => unknown, wait: number, immediate: boolean, cb: () => unknown) { +export function debounce(func: (...args: any) => unknown, wait: number, immediate?: boolean, cb?: () => unknown) { let timeout: NodeJS.Timeout|null; return function fx(...args: Array) { const runLater = () => { diff --git a/app/mm-redux/actions/integrations.ts b/app/mm-redux/actions/integrations.ts index 4c6a3c3ed..2401f2a85 100644 --- a/app/mm-redux/actions/integrations.ts +++ b/app/mm-redux/actions/integrations.ts @@ -13,7 +13,7 @@ import {analytics} from '@init/analytics.ts'; import {batchActions, DispatchFunc, GetStateFunc, ActionFunc} from '@mm-redux/types/actions'; -import {Command, DialogSubmission, IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mm-redux/types/integrations'; +import {Command, CommandArgs, DialogSubmission, IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mm-redux/types/integrations'; import {logError} from './errors'; import {bindClientFunc, forceLogoutIfNecessary, requestSuccess, requestFailure} from './helpers'; @@ -189,7 +189,7 @@ export function getAutocompleteCommands(teamId: string, page = 0, perPage: numbe }); } -export function getCommandAutocompleteSuggestions(userInput: string, teamId: string, commandArgs: any): ActionFunc { +export function getCommandAutocompleteSuggestions(userInput: string, teamId: string, commandArgs: CommandArgs): ActionFunc { return async (dispatch: DispatchFunc) => { let data: any = null; try { @@ -236,7 +236,7 @@ export function editCommand(command: Command): ActionFunc { }); } -export function executeCommand(command: Command, args: Array): ActionFunc { +export function executeCommand(command: string, args: CommandArgs): ActionFunc { return bindClientFunc({ clientFunc: Client4.executeCommand, params: [ @@ -457,7 +457,7 @@ export function handleGotoLocation(href: string, intl: any): ActionFunc { break; } } else { - const {formatMessage} = this.context.intl; + const {formatMessage} = intl; const onError = () => Alert.alert( formatMessage({ id: 'mobile.server_link.error.title', diff --git a/app/mm-redux/client/client4.ts b/app/mm-redux/client/client4.ts index 81049c8fa..41731e49e 100644 --- a/app/mm-redux/client/client4.ts +++ b/app/mm-redux/client/client4.ts @@ -18,6 +18,7 @@ import {CustomEmoji} from '@mm-redux/types/emojis'; import {Config} from '@mm-redux/types/config'; import {Bot, BotPatch} from '@mm-redux/types/bots'; import {SyncablePatch} from '@mm-redux/types/groups'; +import {AppCallRequest, AppCallType} from '@mm-redux/types/apps'; import fetch from './fetch_etag'; import {General} from '../constants'; @@ -3043,6 +3044,35 @@ export default class Client4 { ); }; + // Apps + + getAppsProxyRoute() { + return `${this.url}/plugins/com.mattermost.apps`; + } + + executeAppCall = async (call: AppCallRequest, type: AppCallType) => { + const callCopy = { + ...call, + path: `${call.path}/${type}`, + context: { + ...call.context, + user_agent: 'mobile', + }, + }; + + return this.doFetch( + `${this.getAppsProxyRoute()}/api/v1/call`, + {method: 'post', body: JSON.stringify(callCopy)}, + ); + } + + getAppsBindings = async (userID: string, channelID: string) => { + return this.doFetch( + this.getAppsProxyRoute() + `/api/v1/bindings?user_id=${userID}&channel_id=${channelID}&user_agent_type=mobile`, + {method: 'get'}, + ); + } + // Client Helpers doFetch = async (url: string, options: Options) => { diff --git a/app/mm-redux/constants/apps.ts b/app/mm-redux/constants/apps.ts new file mode 100644 index 000000000..49196a40b --- /dev/null +++ b/app/mm-redux/constants/apps.ts @@ -0,0 +1,46 @@ +// 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'; + +export const AppBindingLocations = { + POST_MENU_ITEM: '/post_menu', + CHANNEL_HEADER_ICON: '/channel_header', + COMMAND: '/command', + IN_POST: '/in_post', +}; + +export const AppBindingPresentations = { + MODAL: 'modal', +}; + +export const AppCallResponseTypes: { [name: string]: AppCallResponseType } = { + OK: 'ok', + ERROR: 'error', + FORM: 'form', + CALL: 'call', + 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', + EXPAND_ALL: 'all', + EXPAND_SUMMARY: 'summary', +}; + +export const AppFieldTypes: { [name: string]: AppFieldType } = { + TEXT: 'text', + STATIC_SELECT: 'static_select', + DYNAMIC_SELECT: 'dynamic_select', + BOOL: 'bool', + USER: 'user', + CHANNEL: 'channel', +}; diff --git a/app/mm-redux/reducers/entities/__snapshots__/apps.test.js.snap b/app/mm-redux/reducers/entities/__snapshots__/apps.test.js.snap new file mode 100644 index 000000000..934192dc2 --- /dev/null +++ b/app/mm-redux/reducers/entities/__snapshots__/apps.test.js.snap @@ -0,0 +1,340 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`bindings Invalid channel header get filtered 1`] = ` +Object { + "bindings": Array [ + Object { + "app_id": "1", + "bindings": Array [ + Object { + "app_id": "1", + "bindings": undefined, + "call": Object {}, + "label": "a", + "location": "/post_menu/locA", + }, + ], + "call": Object {}, + "location": "/post_menu", + }, + Object { + "app_id": "2", + "bindings": Array [ + Object { + "app_id": "2", + "bindings": undefined, + "call": Object {}, + "label": "a", + "location": "/post_menu/locA", + }, + ], + "call": Object {}, + "location": "/post_menu", + }, + Object { + "app_id": "1", + "bindings": Array [ + Object { + "app_id": "1", + "bindings": undefined, + "call": Object {}, + "icon": "icon", + "label": "b", + "location": "/channel_header/locB", + }, + Object { + "app_id": "1", + "bindings": undefined, + "call": Object {}, + "label": "c", + "location": "/channel_header/locC", + }, + ], + "call": Object {}, + "location": "/channel_header", + }, + Object { + "app_id": "2", + "bindings": Array [ + Object { + "app_id": "2", + "bindings": undefined, + "call": Object {}, + "icon": "icon", + "label": "c", + "location": "/channel_header/locC", + }, + ], + "call": Object {}, + "location": "/channel_header", + }, + Object { + "app_id": "3", + "bindings": Array [ + Object { + "app_id": "3", + "bindings": undefined, + "call": Object {}, + "label": "c", + "location": "/channel_header/locC", + }, + ], + "call": Object {}, + "location": "/channel_header", + }, + Object { + "app_id": "3", + "bindings": Array [ + Object { + "app_id": "3", + "bindings": undefined, + "call": Object {}, + "label": "c", + "location": "/command/locC", + }, + ], + "call": Object {}, + "location": "/command", + }, + ], +} +`; + +exports[`bindings Invalid commands get filtered 1`] = ` +Object { + "bindings": Array [ + Object { + "app_id": "1", + "bindings": Array [ + Object { + "app_id": "1", + "bindings": undefined, + "call": Object {}, + "label": "a", + "location": "/post_menu/locA", + }, + Object { + "app_id": "1", + "bindings": undefined, + "call": Object {}, + "label": "a", + "location": "/post_menu/locB", + }, + ], + "call": Object {}, + "location": "/post_menu", + }, + Object { + "app_id": "1", + "bindings": Array [ + Object { + "app_id": "1", + "bindings": undefined, + "call": Object {}, + "icon": "icon", + "label": "b", + "location": "/channel_header/locB", + }, + ], + "call": Object {}, + "location": "/channel_header", + }, + Object { + "app_id": "3", + "bindings": Array [ + Object { + "app_id": "3", + "bindings": Array [ + Object { + "app_id": "3", + "bindings": undefined, + "call": Object {}, + "label": "c2", + "location": "/command/locC/subC2", + }, + ], + "call": Object {}, + "label": "c", + "location": "/command/locC", + }, + ], + "call": Object {}, + "location": "/command", + }, + Object { + "app_id": "1", + "bindings": Array [], + "location": "/command", + }, + Object { + "app_id": "2", + "bindings": Array [ + Object { + "app_id": "2", + "bindings": Array [ + Object { + "app_id": "2", + "bindings": undefined, + "call": Object {}, + "label": "c1", + "location": "/command/locC/subC1", + }, + Object { + "app_id": "2", + "bindings": undefined, + "call": Object {}, + "label": "c2", + "location": "/command/locC/subC2", + }, + ], + "call": Object {}, + "label": "c", + "location": "/command/locC", + }, + ], + "call": Object {}, + "location": "/command", + }, + ], +} +`; + +exports[`bindings Invalid post menu get filtered 1`] = ` +Object { + "bindings": Array [ + Object { + "app_id": "1", + "bindings": Array [ + Object { + "app_id": "1", + "bindings": undefined, + "call": Object {}, + "label": "a", + "location": "/post_menu/locB", + }, + ], + "call": Object {}, + "location": "/post_menu", + }, + Object { + "app_id": "2", + "bindings": Array [ + Object { + "app_id": "2", + "bindings": undefined, + "call": Object {}, + "label": "a", + "location": "/post_menu/locA", + }, + Object { + "app_id": "2", + "bindings": undefined, + "call": Object {}, + "label": "b", + "location": "/post_menu/locB", + }, + ], + "call": Object {}, + "location": "/post_menu", + }, + Object { + "app_id": "3", + "bindings": Array [], + "location": "/post_menu", + }, + Object { + "app_id": "1", + "bindings": Array [ + Object { + "app_id": "1", + "bindings": undefined, + "call": Object {}, + "icon": "icon", + "label": "b", + "location": "/channel_header/locB", + }, + ], + "call": Object {}, + "location": "/channel_header", + }, + Object { + "app_id": "3", + "bindings": Array [ + Object { + "app_id": "3", + "bindings": undefined, + "call": Object {}, + "label": "c", + "location": "/command/locC", + }, + ], + "call": Object {}, + "location": "/command", + }, + ], +} +`; + +exports[`bindings No element get filtered 1`] = ` +Object { + "bindings": Array [ + Object { + "app_id": "1", + "bindings": Array [ + Object { + "app_id": "1", + "bindings": undefined, + "call": Object {}, + "label": "a", + "location": "/post_menu/locA", + }, + ], + "call": Object {}, + "location": "/post_menu", + }, + Object { + "app_id": "2", + "bindings": Array [ + Object { + "app_id": "2", + "bindings": undefined, + "call": Object {}, + "label": "a", + "location": "/post_menu/locA", + }, + ], + "call": Object {}, + "location": "/post_menu", + }, + Object { + "app_id": "1", + "bindings": Array [ + Object { + "app_id": "1", + "bindings": undefined, + "call": Object {}, + "icon": "icon", + "label": "b", + "location": "/channel_header/locB", + }, + ], + "call": Object {}, + "location": "/channel_header", + }, + Object { + "app_id": "3", + "bindings": Array [ + Object { + "app_id": "3", + "bindings": undefined, + "call": Object {}, + "label": "c", + "location": "/command/locC", + }, + ], + "call": Object {}, + "location": "/command", + }, + ], +} +`; diff --git a/app/mm-redux/reducers/entities/apps.test.js b/app/mm-redux/reducers/entities/apps.test.js new file mode 100644 index 000000000..45df0b53a --- /dev/null +++ b/app/mm-redux/reducers/entities/apps.test.js @@ -0,0 +1,360 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AppsTypes} from '@mm-redux/action_types'; +import * as Reducers from './apps'; + +describe('bindings', () => { + const initialState = []; + + test('No element get filtered', () => { + const data = { + bindings: [ + { + app_id: '1', + location: '/post_menu', + bindings: [ + { + location: 'locA', + label: 'a', + call: {}, + }, + ], + }, + { + app_id: '2', + location: '/post_menu', + bindings: [ + { + location: 'locA', + label: 'a', + call: {}, + }, + ], + }, + { + app_id: '1', + location: '/channel_header', + bindings: [ + { + location: 'locB', + label: 'b', + icon: 'icon', + call: {}, + }, + ], + }, + { + app_id: '3', + location: '/command', + bindings: [ + { + location: 'locC', + label: 'c', + call: {}, + }, + ], + }, + ], + }; + + const state = Reducers.bindings( + initialState, + { + type: AppsTypes.RECEIVED_APP_BINDINGS, + data, + }, + ); + + expect(state).toMatchSnapshot(); + }); + + test('Invalid channel header get filtered', () => { + const data = { + bindings: [ + { + app_id: '1', + location: '/post_menu', + bindings: [ + { + location: 'locA', + label: 'a', + call: {}, + }, + ], + }, + { + app_id: '2', + location: '/post_menu', + bindings: [ + { + location: 'locA', + label: 'a', + call: {}, + }, + ], + }, + { + app_id: '1', + location: '/channel_header', + bindings: [ + { + location: 'locB', + label: 'b', + icon: 'icon', + call: {}, + }, + { + location: 'locC', + label: 'c', + call: {}, + }, + ], + }, + { + app_id: '2', + location: '/channel_header', + bindings: [ + { + location: 'locB', + icon: 'icon', + call: {}, + }, + { + location: 'locC', + label: 'c', + icon: 'icon', + call: {}, + }, + ], + }, + { + app_id: '3', + location: '/channel_header', + bindings: [ + { + location: 'locB', + call: {}, + }, + { + location: 'locC', + label: 'c', + call: {}, + }, + ], + }, + { + app_id: '3', + location: '/command', + bindings: [ + { + location: 'locC', + label: 'c', + call: {}, + }, + ], + }, + ], + }; + + const state = Reducers.bindings( + initialState, + { + type: AppsTypes.RECEIVED_APP_BINDINGS, + data, + }, + ); + + expect(state).toMatchSnapshot(); + }); + + test('Invalid post menu get filtered', () => { + const data = { + bindings: [ + { + app_id: '1', + location: '/post_menu', + bindings: [ + { + location: 'locA', + call: {}, + }, + { + location: 'locB', + label: 'a', + call: {}, + }, + ], + }, + { + app_id: '2', + location: '/post_menu', + bindings: [ + { + location: 'locA', + label: 'a', + call: {}, + }, + { + location: 'locB', + label: 'b', + call: {}, + }, + ], + }, + { + app_id: '3', + location: '/post_menu', + bindings: [ + { + location: 'locA', + call: {}, + }, + ], + }, + { + app_id: '1', + location: '/channel_header', + bindings: [ + { + location: 'locB', + label: 'b', + icon: 'icon', + call: {}, + }, + ], + }, + { + app_id: '3', + location: '/command', + bindings: [ + { + location: 'locC', + label: 'c', + call: {}, + }, + ], + }, + ], + }; + + const state = Reducers.bindings( + initialState, + { + type: AppsTypes.RECEIVED_APP_BINDINGS, + data, + }, + ); + + expect(state).toMatchSnapshot(); + }); + + test('Invalid commands get filtered', () => { + const data = { + bindings: [ + { + app_id: '1', + location: '/post_menu', + bindings: [ + { + location: 'locA', + label: 'a', + call: {}, + }, + { + location: 'locB', + label: 'a', + call: {}, + }, + ], + }, + { + app_id: '1', + location: '/channel_header', + bindings: [ + { + location: 'locB', + label: 'b', + icon: 'icon', + call: {}, + }, + ], + }, + { + app_id: '3', + location: '/command', + bindings: [ + { + location: 'locC', + label: 'c', + bindings: [ + { + location: 'subC1', + call: {}, + }, + { + location: 'subC2', + label: 'c2', + call: {}, + }, + ], + }, + { + location: 'locD', + label: 'd', + bindings: [ + { + location: 'subC1', + call: {}, + }, + ], + }, + ], + }, + { + app_id: '1', + location: '/command', + bindings: [ + { + location: 'locC', + call: {}, + }, + ], + }, + { + app_id: '2', + location: '/command', + bindings: [ + { + location: 'locC', + label: 'c', + call: {}, + bindings: [ + { + location: 'subC1', + label: 'c1', + call: {}, + }, + { + location: 'subC2', + label: 'c2', + call: {}, + }, + ], + }, + ], + }, + ], + }; + + const state = Reducers.bindings( + initialState, + { + type: AppsTypes.RECEIVED_APP_BINDINGS, + data, + }, + ); + + expect(state).toMatchSnapshot(); + }); +}); diff --git a/app/mm-redux/reducers/entities/apps.ts b/app/mm-redux/reducers/entities/apps.ts new file mode 100644 index 000000000..c8a7dc109 --- /dev/null +++ b/app/mm-redux/reducers/entities/apps.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {combineReducers} from 'redux'; + +import {AppsTypes} from '@mm-redux/action_types'; +import {AppBinding, AppsState} from '@mm-redux/types/apps'; +import {GenericAction} from '@mm-redux/types/actions'; +import {validateBindings} from '@utils/apps'; + +export function bindings(state: AppBinding[] = [], action: GenericAction): AppBinding[] { + switch (action.type) { + case AppsTypes.RECEIVED_APP_BINDINGS: { + validateBindings(action.data); + return action.data || []; + } + default: + return state; + } +} + +export default (combineReducers({ + bindings, +}) as (b: AppsState, a: GenericAction) => AppsState); diff --git a/app/mm-redux/reducers/entities/index.ts b/app/mm-redux/reducers/entities/index.ts index 06cec9205..5e58d7aa7 100644 --- a/app/mm-redux/reducers/entities/index.ts +++ b/app/mm-redux/reducers/entities/index.ts @@ -21,6 +21,7 @@ import schemes from './schemes'; import groups from './groups'; import bots from './bots'; import channelCategories from './channel_categories'; +import apps from './apps'; export default combineReducers({ general, @@ -41,4 +42,5 @@ export default combineReducers({ groups, bots, channelCategories, + apps, }); diff --git a/app/mm-redux/selectors/entities/apps.ts b/app/mm-redux/selectors/entities/apps.ts new file mode 100644 index 000000000..85673bb98 --- /dev/null +++ b/app/mm-redux/selectors/entities/apps.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {GlobalState} from '@mm-redux/types/store'; +import {AppBinding} from '@mm-redux/types/apps'; + +export function getAppsBindings(state: GlobalState, location?: string): AppBinding[] { + if (!state.entities.apps.bindings) { + return []; + } + + if (location) { + const bindings = state.entities.apps.bindings.filter((b) => b.location === location); + return bindings.reduce((accum: AppBinding[], current: AppBinding) => accum.concat(current.bindings || []), []); + } + return state.entities.apps.bindings; +} diff --git a/app/mm-redux/types/apps.ts b/app/mm-redux/types/apps.ts new file mode 100644 index 000000000..3361dee07 --- /dev/null +++ b/app/mm-redux/types/apps.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export type AppManifest = { + app_id: string; + display_name: string; + description?: string; + homepage_url?: string; + root_url: string; +} + +export type AppModalState = { + form: AppForm; + call: AppCallRequest; +} + +export type AppsState = { + bindings: AppBinding[]; +}; + +export type AppBinding = { + app_id: string; + location?: string; + icon?: string; + + // Label is the (usually short) primary text to display at the location. + // - For LocationPostMenu is the menu item text. + // - For LocationChannelHeader is the dropdown text. + // - For LocationCommand is the name of the command + label: string; + + // Hint is the secondary text to display + // - LocationPostMenu: not used + // - LocationChannelHeader: tooltip + // - LocationCommand: the "Hint" line + hint?: string; + + // Description is the (optional) extended help text, used in modals and autocomplete + description?: string; + + role_id?: string; + depends_on_team?: boolean; + depends_on_channel?: boolean; + 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; + bindings?: AppBinding[]; + form?: AppForm; +}; + +export type AppCallValues = { + [name: string]: any; +}; + +export type AppCallType = string; + +export type AppCall = { + path: string; + expand?: AppExpand; + state?: any; +}; + +export type AppCallRequest = AppCall & { + context: AppContext; + values?: AppCallValues; + raw_command?: string; + selected_field?: string; + query?: string; +}; + +export type AppCallResponseType = string; + +export type AppCallResponse = { + type: AppCallResponseType; + markdown?: string; + data?: Res; + error?: string; + navigate_to_url?: string; + use_external_browser?: boolean; + call?: AppCall; + form?: AppForm; +}; + +export type AppContext = { + app_id: string; + location?: string; + acting_user_id?: string; + user_id?: string; + channel_id?: string; + team_id?: string; + post_id?: string; + root_id?: string; + props?: AppContextProps; + user_agent?: string; +}; + +export type AppContextProps = { + [name: string]: string; +}; + +export type AppExpandLevel = string; + +export type AppExpand = { + app?: AppExpandLevel; + acting_user?: AppExpandLevel; + channel?: AppExpandLevel; + config?: AppExpandLevel; + mentioned?: AppExpandLevel; + parent_post?: AppExpandLevel; + post?: AppExpandLevel; + root_post?: AppExpandLevel; + team?: AppExpandLevel; + user?: AppExpandLevel; +}; + +export type AppForm = { + title?: string; + header?: string; + footer?: string; + icon?: string; + submit_buttons?: string; + cancel_button?: boolean; + submit_on_cancel?: boolean; + fields: AppField[]; + call?: AppCall; + depends_on?: string[]; +}; + +export type AppFormValue = string | AppSelectOption | boolean | null; +export type AppFormValues = {[name: string]: AppFormValue}; + +export type AppSelectOption = { + label: string; + value: string; + icon_data?: string; +}; + +export type AppFieldType = string; + +// This should go in mattermost-redux +export type AppField = { + + // Name is the name of the JSON field to use. + name: string; + type: AppFieldType; + is_required?: boolean; + readonly?: boolean; + + // Present (default) value of the field + value?: AppFormValue; + + description?: string; + + label?: string; + hint?: string; + position?: number; + + modal_label?: string; + + // Select props + refresh?: boolean; + options?: AppSelectOption[]; + multiselect?: boolean; + + // Text props + subtype?: string; + min_length?: number; + max_length?: number; +}; + +export type AutocompleteSuggestion = { + suggestion: string; + complete?: string; + description?: string; + hint?: string; + iconData?: string; +} + +export type AutocompleteSuggestionWithComplete = AutocompleteSuggestion & { + complete: string; +} + +export type AutocompleteElement = AppField; +export type AutocompleteStaticSelect = AutocompleteElement & { + options: AppSelectOption[]; +}; + +export type AutocompleteDynamicSelect = AutocompleteElement; + +export type AutocompleteUserSelect = AutocompleteElement; + +export type AutocompleteChannelSelect = AutocompleteElement; + +export type FormResponseData = { + errors?: { + [field: string]: string; + }; +} + +export type AppLookupResponse = { + items: AppSelectOption[]; +} diff --git a/app/mm-redux/types/integration_actions.ts b/app/mm-redux/types/integration_actions.ts new file mode 100644 index 000000000..0b5e747c5 --- /dev/null +++ b/app/mm-redux/types/integration_actions.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export type PostAction = { + id?: string; + type?: string; + name?: string; + disabled?: boolean; + style?: string; + data_source?: string; + options?: PostActionOption[]; + default_option?: string; + integration?: PostActionIntegration; + cookie?: string; +}; + +export type PostActionOption = { + text: string; + value: string; +}; + +export type PostActionIntegration = { + url?: string; + context?: Record; +} + +export type PostActionResponse = { + status: string; + trigger_id: string; +}; diff --git a/app/mm-redux/types/integrations.ts b/app/mm-redux/types/integrations.ts index 9e0f2f1e3..cf18c214b 100644 --- a/app/mm-redux/types/integrations.ts +++ b/app/mm-redux/types/integrations.ts @@ -111,3 +111,24 @@ export type DialogElement = { value: any; }>; }; +export type InteractiveDialogConfig = { + app_id: string; + trigger_id: string; + url: string; + dialog: { + callback_id: string; + title: string; + introduction_text: string; + icon_url?: string; + elements: DialogElement[]; + submit_label: string; + notify_on_cancel: boolean; + state: string; + }; +}; +export type CommandArgs = { + channel_id: string; + team_id: string; + root_id?: string; + parent_id?: string; +} diff --git a/app/mm-redux/types/store.ts b/app/mm-redux/types/store.ts index e0822111f..0ff85ec1a 100644 --- a/app/mm-redux/types/store.ts +++ b/app/mm-redux/types/store.ts @@ -20,6 +20,7 @@ import {PreferenceType} from './preferences'; import {Bot} from './bots'; import {ChannelCategoriesState} from './channel_categories'; import {Dictionary} from './utilities'; +import {AppsState} from './apps'; export type GlobalState = { entities: { @@ -52,6 +53,7 @@ export type GlobalState = { gifs: any; groups: GroupsState; channelCategories: ChannelCategoriesState; + apps: AppsState; }; errors: Array; requests: { diff --git a/app/screens/app_selector_screen/__snapshots__/app_selector_screen.test.tsx.snap b/app/screens/app_selector_screen/__snapshots__/app_selector_screen.test.tsx.snap new file mode 100644 index 000000000..4d657a46c --- /dev/null +++ b/app/screens/app_selector_screen/__snapshots__/app_selector_screen.test.tsx.snap @@ -0,0 +1,626 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SelectorScreen should match snapshot for channels 1`] = ` + + + + + + + +`; + +exports[`SelectorScreen should match snapshot for channels 2`] = ` + + + + + + + +`; + +exports[`SelectorScreen should match snapshot for explicit options 1`] = ` + + + + + + + +`; + +exports[`SelectorScreen should match snapshot for searching 1`] = ` + + + + + + + +`; + +exports[`SelectorScreen should match snapshot for users 1`] = ` + + + + + + + +`; + +exports[`SelectorScreen should match snapshot for users 2`] = ` + + + + + + + +`; diff --git a/app/screens/app_selector_screen/app_selector_screen.test.tsx b/app/screens/app_selector_screen/app_selector_screen.test.tsx new file mode 100644 index 000000000..5a4003e6f --- /dev/null +++ b/app/screens/app_selector_screen/app_selector_screen.test.tsx @@ -0,0 +1,127 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {shallow} from 'enzyme'; +import {IntlProvider} from 'react-intl'; + +import Preferences from '@mm-redux/constants/preferences'; + +import AppSelectorScreen from './app_selector_screen'; +import {Channel} from '@mm-redux/types/channels'; +import {UserProfile} from '@mm-redux/types/users'; + +const user1 = {id: 'id', username: 'username'} as UserProfile; +const user2 = {id: 'id2', username: 'username2'} as UserProfile; + +const getProfiles = async () => { + return { + data: [user1, user2], + error: {}, + }; +}; + +const searchProfiles = async () => { + return { + data: [user2], + error: {}, + }; +}; + +const channel1 = {id: 'id', name: 'name', display_name: 'display_name'} as Channel; +const channel2 = {id: 'id2', name: 'name2', display_name: 'display_name2'} as Channel; + +const getChannels = async () => { + return { + data: [channel1, channel2], + error: {}, + }; +}; + +const searchChannels = async () => { + return { + data: [channel2], + error: {}, + }; +}; + +const intlProvider = new IntlProvider({locale: 'en'}, {}); +const {intl} = intlProvider.getChildContext(); + +describe('SelectorScreen', () => { + const actions = { + getProfiles, + getChannels, + searchProfiles, + searchChannels, + }; + + const baseProps = { + actions, + currentTeamId: 'someId', + onSelect: jest.fn(), + data: [{label: 'text', value: 'value'}], + theme: Preferences.THEMES.default, + }; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + test('should match snapshot for explicit options', async () => { + const wrapper = shallow( + , + {context: {intl}}, + ); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot for users', async () => { + const props = { + ...baseProps, + dataSource: 'users', + data: [user1, user2], + }; + + const wrapper = shallow( + , + {context: {intl}}, + ); + expect(wrapper.getElement()).toMatchSnapshot(); + wrapper.setState({isLoading: false}); + wrapper.update(); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot for channels', async () => { + const props = { + ...baseProps, + dataSource: 'channels', + data: [channel1, channel2], + }; + + const wrapper = shallow( + , + {context: {intl}}, + ); + expect(wrapper.getElement()).toMatchSnapshot(); + wrapper.setState({isLoading: false}); + wrapper.update(); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot for searching', async () => { + const props = { + ...baseProps, + dataSource: 'channels', + data: [channel1, channel2], + }; + + const wrapper = shallow( + , + {context: {intl}}, + ); + wrapper.setState({isLoading: false, searching: true, term: 'name2'}); + wrapper.update(); + expect(wrapper.getElement()).toMatchSnapshot(); + }); +}); diff --git a/app/screens/app_selector_screen/app_selector_screen.tsx b/app/screens/app_selector_screen/app_selector_screen.tsx new file mode 100644 index 000000000..3c7c3f0b9 --- /dev/null +++ b/app/screens/app_selector_screen/app_selector_screen.tsx @@ -0,0 +1,465 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import {intlShape} from 'react-intl'; +import { + Platform, + View, +} from 'react-native'; +import {SafeAreaView} from 'react-native-safe-area-context'; + +import {popTopScreen} from '@actions/navigation'; +import CustomList, {FLATLIST} from '@components/custom_list'; +import UserListRow from '@components/custom_list/user_list_row'; +import ChannelListRow from '@components/custom_list/channel_list_row'; +import OptionListRow from '@components/custom_list/option_list_row'; +import FormattedText from '@components/formatted_text'; +import SearchBar from '@components/search_bar'; +import StatusBar from '@components/status_bar'; +import {ViewTypes} from '@constants'; +import {debounce} from '@mm-redux/actions/helpers'; +import {General} from '@mm-redux/constants'; +import {filterProfilesMatchingTerm} from '@mm-redux/utils/user_utils'; +import {filterChannelsMatchingTerm} from '@mm-redux/utils/channel_utils'; +import {memoizeResult} from '@mm-redux/utils/helpers'; +import {t} from '@utils/i18n'; +import {loadingText} from '@utils/member_list'; +import { + changeOpacity, + makeStyleSheetFromTheme, + getKeyboardAppearanceFromTheme, +} from '@utils/theme'; +import {AppSelectOption} from '@mm-redux/types/apps'; +import {Theme} from '@mm-redux/types/preferences'; +import {ActionResult} from '@mm-redux/types/actions'; +import {Channel} from '@mm-redux/types/channels'; +import {UserProfile} from '@mm-redux/types/users'; + +type Props = { + currentTeamId: string; + data: OptionsData; + dataSource?: string; + onSelect: (option: UserProfile | Channel | AppSelectOption) => void; + theme: Theme; + performLookupCall?: (userInput: string) => Promise; + actions: { + getProfiles: (page?: number, perPage?: number, options?: any) => Promise; + getChannels: (teamId: string, page?: number, perPage?: number) => Promise; + searchProfiles: (term: string, options?: any) => Promise; + searchChannels: (teamId: string, term: string, archived?: boolean) => Promise; + } +} + +type State = { + data: OptionsData; + loading: boolean; + searchResults: OptionsData; + term: string; +} + +type OptionsData = UserProfile[] | Channel[] | AppSelectOption[]; + +type RowProps = { + id: string; + item: UserProfile | Channel | AppSelectOption; + selected: boolean; + selectable: boolean; + enabled: boolean; + onPress: (id: string, item: UserProfile | Channel | AppSelectOption) => void; +} + +export default class AppSelectorScreen extends PureComponent { + private searchTimeoutId?: NodeJS.Timeout; + private page = -1; + private next: boolean; + + static contextTypes = { + intl: intlShape.isRequired, + }; + + constructor(props: Props) { + super(props); + + this.next = props.dataSource === ViewTypes.DATA_SOURCE_USERS || + props.dataSource === ViewTypes.DATA_SOURCE_CHANNELS || + props.dataSource === 'app'; + + let data: OptionsData = []; + if (!props.dataSource) { + data = props.data; + } + + this.state = { + data, + loading: false, + searchResults: [], + term: '', + }; + } + + componentDidMount() { + const {dataSource} = this.props; + if (dataSource === ViewTypes.DATA_SOURCE_USERS) { + this.getProfiles(); + } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { + this.getChannels(); + } else if (dataSource === 'app') { + this.getAppOptions(); + } + } + + clearSearch = () => { + this.setState({term: '', searchResults: []}); + }; + + close = () => { + popTopScreen(); + }; + + handleSelectItem = (id: string, item: UserProfile | Channel | AppSelectOption) => { + this.props.onSelect(item); + this.close(); + }; + + getChannels = debounce(() => { + const {actions, currentTeamId} = this.props; + const {loading, term} = this.state; + if (this.next && !loading && !term) { + this.setState({loading: true}, () => { + actions.getChannels( + currentTeamId, + this.page += 1, + General.CHANNELS_CHUNK_SIZE, + ).then(this.loadedChannels); + }); + } + }, 100); + + getAppOptions = debounce(() => { + const {performLookupCall} = this.props; + const {loading, term} = this.state; + if (this.next && !loading && !term) { + this.setState({loading: true}, () => { + performLookupCall?.(term).then(this.loadedAppOptions); + }); + } + }, 100) + + getDataResults = () => { + const {dataSource} = this.props; + const {data, searchResults, term} = this.state; + + const result = { + data, + listType: FLATLIST}; + if (term) { + result.data = filterSearchData(dataSource, searchResults, term); + } + + // TODO re-add this to add sections by first username letter if desired + // } else if (dataSource === ViewTypes.DATA_SOURCE_USERS) { + // result.data = createProfilesSections(data); + // result.listType = SECTIONLIST; + // } + + return result; + }; + + getProfiles = debounce(() => { + const {loading, term} = this.state; + if (this.next && !loading && !term) { + this.setState({loading: true}, () => { + const {actions} = this.props; + + actions.getProfiles( + this.page + 1, + General.PROFILE_CHUNK_SIZE, + ).then(this.loadedProfiles); + }); + } + }, 100); + + loadedChannels = ({data: channels}: {data: Channel[]}) => { + const data = this.state.data as Channel[]; + if (channels && !channels.length) { + this.next = false; + } + + this.page += 1; + this.setState({loading: false, data: [...channels, ...data]}); + }; + + loadedProfiles = ({data: profiles}: {data: UserProfile[]}) => { + const data = this.state.data as UserProfile[]; + if (profiles && !profiles.length) { + this.next = false; + } + + this.page += 1; + this.setState({loading: false, data: [...profiles, ...data]}); + }; + + loadedAppOptions = (options: AppSelectOption[]) => { + const data = this.state.data as AppSelectOption[]; + if (options && !options.length) { + this.next = false; + } + + this.page += 1; + this.setState({loading: false, data: [...options, ...data]}); + } + + loadMore = () => { + const {dataSource} = this.props; + + if (dataSource === ViewTypes.DATA_SOURCE_USERS) { + this.getProfiles(); + } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { + this.getChannels(); + } + }; + + onSearch = (text: string) => { + if (text) { + const {dataSource, data} = this.props; + this.setState({term: text}); + if (this.searchTimeoutId) { + clearTimeout(this.searchTimeoutId); + } + + this.searchTimeoutId = setTimeout(() => { + if (!dataSource) { + this.setState({searchResults: filterSearchData(null, data, text)}); + return; + } + + if (dataSource === ViewTypes.DATA_SOURCE_USERS) { + this.searchProfiles(text); + } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { + this.searchChannels(text); + } + }, General.SEARCH_TIMEOUT_MILLISECONDS); + } else { + this.clearSearch(); + } + }; + + searchChannels = (term: string) => { + const {actions, currentTeamId} = this.props; + + actions.searchChannels(currentTeamId, term.toLowerCase()).then(({data}) => { + this.setState({searchResults: data, loading: false}); + }); + }; + + searchProfiles = (term: string) => { + const {actions} = this.props; + this.setState({loading: true}); + + actions.searchProfiles(term.toLowerCase()).then((results) => { + let data = []; + if (results.data) { + data = results.data; + } + this.setState({searchResults: data, loading: false}); + }); + }; + + renderLoading = () => { + const {dataSource, theme} = this.props; + const {loading} = this.state; + const style = getStyleFromTheme(theme); + + if (!loading) { + return null; + } + + let text; + switch (dataSource) { + case ViewTypes.DATA_SOURCE_USERS: + text = loadingText; + break; + case ViewTypes.DATA_SOURCE_CHANNELS: + text = { + id: t('mobile.loading_channels'), + defaultMessage: 'Loading Channels...', + }; + break; + default: + text = { + id: t('mobile.loading_options'), + defaultMessage: 'Loading Options...', + }; + break; + } + + return ( + + + + ); + }; + + renderNoResults = () => { + const {loading} = this.state; + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + if (loading || this.page === -1) { + return null; + } + + return ( + + + + ); + }; + + renderChannelItem = (props: RowProps) => { + return ; + }; + + renderOptionItem = (props: RowProps) => { + const item = props.item as AppSelectOption; + const newProps = { + ...props, + item: {text: item.label, value: item.value}, + onPress: (id: string, option: {text: string, value: string}) => { + props.onPress(id, {label: option.text, value: option.value}); + }, + }; + return ; + }; + + renderUserItem = (props: RowProps) => { + return ; + }; + + render() { + const {formatMessage} = this.context.intl; + const {theme, dataSource} = this.props; + const {loading, term} = this.state; + const style = getStyleFromTheme(theme); + + const searchBarInput = { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + color: theme.centerChannelColor, + fontSize: 15, + }; + + let rowComponent; + if (dataSource === ViewTypes.DATA_SOURCE_USERS) { + rowComponent = this.renderUserItem; + } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { + rowComponent = this.renderChannelItem; + } else { + rowComponent = this.renderOptionItem; + } + + const {data, listType} = this.getDataResults(); + + return ( + + + + + + + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + }, + searchBar: { + marginVertical: 5, + height: 38, + ...Platform.select({ + ios: { + paddingLeft: 8, + }, + }), + }, + loadingContainer: { + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + height: 70, + justifyContent: 'center', + }, + loadingText: { + color: changeOpacity(theme.centerChannelColor, 0.6), + }, + noResultContainer: { + flexGrow: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + noResultText: { + fontSize: 26, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + }; +}); + +const filterSearchData = memoizeResult((dataSource: string, data: OptionsData, term: string): OptionsData => { + if (!data) { + return []; + } + + const lowerCasedTerm = term.toLowerCase(); + if (dataSource === ViewTypes.DATA_SOURCE_USERS) { + const users = data as UserProfile[]; + return filterProfilesMatchingTerm(users, lowerCasedTerm); + } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { + const channels = data as Channel[]; + return filterChannelsMatchingTerm(channels, lowerCasedTerm); + } + + const options = data as AppSelectOption[]; + return options.filter((option) => option.label && option.label.toLowerCase().startsWith(lowerCasedTerm)); +}); diff --git a/app/screens/app_selector_screen/index.ts b/app/screens/app_selector_screen/index.ts new file mode 100644 index 000000000..93a170de8 --- /dev/null +++ b/app/screens/app_selector_screen/index.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; +import {connect} from 'react-redux'; + +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {getProfiles, searchProfiles} from '@mm-redux/actions/users'; +import {getChannels, searchChannels} from '@mm-redux/actions/channels'; +import AppSelectorScreen from './app_selector_screen'; +import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions'; +import {GlobalState} from '@mm-redux/types/store'; + +function mapStateToProps(state: GlobalState) { + return { + currentTeamId: getCurrentTeamId(state), + theme: getTheme(state), + }; +} + +type Actions = { + getProfiles: (page?: number, perPage?: number, options?: any) => Promise; + getChannels: (teamId: string, page?: number, perPage?: number) => Promise; + searchProfiles: (term: string, options?: any) => Promise; + searchChannels: (teamId: string, term: string, archived?: boolean) => Promise; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators, Actions>({ + getProfiles, + getChannels, + searchProfiles, + searchChannels, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(AppSelectorScreen); diff --git a/app/screens/apps_form/__snapshots__/dialog_introduction_text.test.tsx.snap b/app/screens/apps_form/__snapshots__/dialog_introduction_text.test.tsx.snap new file mode 100644 index 000000000..6fb67710b --- /dev/null +++ b/app/screens/apps_form/__snapshots__/dialog_introduction_text.test.tsx.snap @@ -0,0 +1,126 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DialogIntroductionText should not render the component with an empty value 1`] = `null`; + +exports[`DialogIntroductionText should render the introduction text correctly 1`] = ` + + + +`; diff --git a/app/screens/apps_form/app_form_selector/app_form_selector.tsx b/app/screens/apps_form/app_form_selector/app_form_selector.tsx new file mode 100644 index 000000000..2dad08442 --- /dev/null +++ b/app/screens/apps_form/app_form_selector/app_form_selector.tsx @@ -0,0 +1,280 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent, ReactNode} from 'react'; +import {Text, View} from 'react-native'; +import {intlShape} from 'react-intl'; + +import {displayUsername} from '@mm-redux/utils/user_utils'; + +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {preventDoubleTap} from '@utils/tap'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; +import {ViewTypes} from '@constants'; +import {goToScreen} from '@actions/navigation'; +import {AppSelectOption} from '@mm-redux/types/apps'; +import {Theme} from '@mm-redux/types/preferences'; +import {UserProfile} from '@mm-redux/types/users'; +import {Channel} from '@mm-redux/types/channels'; + +type Props = { + label?: string; + placeholder?: string; + dataSource?: string; + options?: AppSelectOption[]; + selected?: AppSelectOption; + optional?: boolean; + showRequiredAsterisk?: boolean; + teammateNameDisplay?: string; + theme: Theme; + onSelected?: (option: AppSelectOption) => void; + helpText?: string; + errorText?: ReactNode; + roundedBorders?: boolean; + disabled?: boolean; + performLookupCall?: (term: string) => Promise; +} + +export default class AppFormSelector extends PureComponent { + static contextTypes = { + intl: intlShape, + }; + + static defaultProps = { + optional: false, + showRequiredAsterisk: false, + roundedBorders: true, + }; + + handleSelect = (selected: UserProfile | Channel | AppSelectOption) => { + if (!selected) { + return; + } + + const { + dataSource, + teammateNameDisplay, + } = this.props; + + let selectedLabel; + let selectedValue; + if (dataSource === ViewTypes.DATA_SOURCE_USERS) { + const user = selected as UserProfile; + selectedLabel = user.username; + if (teammateNameDisplay) { + selectedLabel = displayUsername(user, teammateNameDisplay); + } + selectedValue = user.id; + } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { + const channel = selected as Channel; + selectedLabel = channel.display_name; + selectedValue = channel.id; + } else { + const option = selected as AppSelectOption; + selectedLabel = option.label; + selectedValue = option.value; + } + + const selectedOption = {label: selectedLabel, value: selectedValue}; + + if (this.props.onSelected) { + this.props.onSelected(selectedOption); + } + }; + + goToSelectorScreen = preventDoubleTap(() => { + const {formatMessage} = this.context.intl; + const {dataSource, options, placeholder, performLookupCall} = this.props; + const screen = 'AppSelectorScreen'; + const title = placeholder || formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'}); + + const selectorProps = { + data: options, + dataSource, + onSelect: this.handleSelect, + performLookupCall, + }; + + goToScreen(screen, title, selectorProps); + }); + + render() { + const {intl} = this.context; + const { + placeholder, + theme, + label, + helpText, + errorText, + optional, + showRequiredAsterisk, + roundedBorders, + disabled, + selected, + } = this.props; + const style = getStyleSheet(theme); + + let text = placeholder || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'}); + let selectedStyle = style.dropdownPlaceholder; + + if (selected) { + text = selected.label; + selectedStyle = style.dropdownSelected; + } + + let inputStyle = style.input; + if (roundedBorders) { + inputStyle = style.roundedInput; + } + + let optionalContent; + let asterisk; + if (optional) { + optionalContent = ( + + ); + } else if (showRequiredAsterisk) { + asterisk = {' *'}; + } + + let labelContent; + if (label) { + labelContent = ( + + + {label} + + {asterisk} + {optionalContent} + + ); + } + + let helpTextContent; + if (helpText) { + helpTextContent = ( + + {helpText} + + ); + } + + let errorTextContent; + if (errorText) { + errorTextContent = ( + + {errorText} + + ); + } + + return ( + + {labelContent} + + + + {text} + + + + + {helpTextContent} + {errorTextContent} + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + const input = { + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.1), + backgroundColor: changeOpacity(theme.centerChannelBg, 0.9), + paddingLeft: 10, + paddingRight: 30, + paddingVertical: 7, + height: 40, + }; + + return { + container: { + width: '100%', + marginBottom: 2, + marginRight: 8, + marginTop: 10, + }, + roundedInput: { + ...input, + borderRadius: 5, + }, + input, + dropdownPlaceholder: { + top: 3, + marginLeft: 5, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + dropdownSelected: { + top: 3, + marginLeft: 5, + color: theme.centerChannelColor, + }, + icon: { + position: 'absolute', + top: 13, + right: 12, + }, + labelContainer: { + flexDirection: 'row', + marginTop: 15, + marginBottom: 10, + }, + label: { + fontSize: 14, + color: theme.centerChannelColor, + marginLeft: 15, + }, + optional: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 14, + marginLeft: 5, + }, + helpText: { + fontSize: 12, + color: changeOpacity(theme.centerChannelColor, 0.5), + marginHorizontal: 15, + marginVertical: 10, + }, + errorText: { + fontSize: 12, + color: theme.errorTextColor, + marginHorizontal: 15, + marginVertical: 10, + }, + asterisk: { + color: theme.errorTextColor, + fontSize: 14, + }, + disabled: { + opacity: 0.5, + }, + }; +}); diff --git a/app/screens/apps_form/app_form_selector/index.ts b/app/screens/apps_form/app_form_selector/index.ts new file mode 100644 index 000000000..a18fa1f46 --- /dev/null +++ b/app/screens/apps_form/app_form_selector/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences'; + +import AppFormSelector from './app_form_selector'; +import {GlobalState} from '@mm-redux/types/store'; + +function mapStateToProps(state: GlobalState) { + return { + teammateNameDisplay: getTeammateNameDisplaySetting(state), + theme: getTheme(state), + }; +} + +export default connect(mapStateToProps)(AppFormSelector); diff --git a/app/screens/apps_form/apps_form_component.tsx b/app/screens/apps_form/apps_form_component.tsx new file mode 100644 index 000000000..87e0d4a59 --- /dev/null +++ b/app/screens/apps_form/apps_form_component.tsx @@ -0,0 +1,390 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {intlShape} from 'react-intl'; +import React, {PureComponent} from 'react'; +import {ScrollView} from 'react-native'; +import {EventSubscription, Navigation} from 'react-native-navigation'; +import {SafeAreaView} from 'react-native-safe-area-context'; + +import {checkDialogElementForError, checkIfErrorsMatchElements} from '@mm-redux/utils/integration_utils'; + +import ErrorText from 'app/components/error_text'; +import StatusBar from 'app/components/status_bar'; +import FormattedText from 'app/components/formatted_text'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; +import {dismissModal} from 'app/actions/navigation'; + +import DialogIntroductionText from './dialog_introduction_text'; +import {Theme} from '@mm-redux/types/preferences'; +import {AppCallRequest, AppCallResponse, AppField, AppForm, AppFormValue, AppFormValues, AppLookupResponse, AppSelectOption, FormResponseData} from '@mm-redux/types/apps'; +import {DialogElement} from '@mm-redux/types/integrations'; +import {AppCallResponseTypes} from '@mm-redux/constants/apps'; +import AppsFormField from './apps_form_field'; + +export type Props = { + call: AppCallRequest; + form: AppForm; + actions: { + submit: (submission: { + values: { + [name: string]: string; + }; + }) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>; + performLookupCall: (field: AppField, values: AppFormValues, userInput: string) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>; + refreshOnSelect: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>; + }; + theme: Theme; + componentId: string; +} + +type State = { + values: {[name: string]: string}; + formError: string | null; + fieldErrors: {[name: string]: React.ReactNode}; + submitting: boolean; + form: AppForm; +} + +const initFormValues = (form: AppForm): {[name: string]: string} => { + const values: {[name: string]: any} = {}; + if (form && form.fields) { + form.fields.forEach((f) => { + values[f.name] = f.value || null; + }); + } + + return values; +}; + +export default class AppsFormComponent extends PureComponent { + private scrollView: React.RefObject; + navigationEventListener?: EventSubscription; + + static contextTypes = { + intl: intlShape.isRequired, + }; + + constructor(props: Props) { + super(props); + + const {form} = props; + const values = initFormValues(form); + + this.state = { + values, + formError: null, + fieldErrors: {}, + submitting: false, + form, + }; + + this.scrollView = React.createRef(); + } + + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + if (nextProps.form !== prevState.form) { + return { + values: initFormValues(nextProps.form), + form: nextProps.form, + }; + } + + return null; + } + + componentDidMount() { + this.navigationEventListener = Navigation.events().bindComponent(this); + } + + navigationButtonPressed({buttonId}: {buttonId: string}) { + switch (buttonId) { + case 'submit-form': + this.handleSubmit(); + return; + case 'close-dialog': + this.handleHide(); + return; + } + + if (buttonId.startsWith('submit-form_')) { + this.handleSubmit(buttonId.substr('submit-form_'.length)); + } + } + + handleSubmit = async (button?: string) => { + const {fields} = this.props.form; + const values = this.state.values; + const fieldErrors: {[name: string]: React.ReactNode} = {}; + + const elements = fieldsAsElements(fields); + elements?.forEach((element) => { + const error = checkDialogElementForError( // TODO: make sure all required values are present in `element` + element, + values[element.name], + ); + if (error) { + fieldErrors[element.name] = ( + + ); + } + }); + + this.setState({fieldErrors}); + + if (Object.keys(fieldErrors).length !== 0) { + return; + } + + const submission = { + values, + }; + + if (button && this.props.form.submit_buttons) { + submission.values[this.props.form.submit_buttons] = button; + } + + const res = await this.props.actions.submit(submission); + if (res.error) { + const errorResponse = res.error; + const errorMessage = errorResponse.error; + const hasErrors = this.updateErrors(elements, errorResponse.data?.errors, errorMessage); + if (!hasErrors) { + this.handleHide(); + } + return; + } + + const callResponse = res.data!; + switch (callResponse.type) { + case AppCallResponseTypes.OK: + case AppCallResponseTypes.NAVIGATE: + this.handleHide(); + return; + case AppCallResponseTypes.FORM: + return; + default: + this.updateErrors([], undefined, this.context.intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResponse.type, + })); + } + } + + updateErrors = (elements: DialogElement[], fieldErrors?: {[x: string]: string}, formError?: string): boolean => { + let hasErrors = false; + + if (fieldErrors && + Object.keys(fieldErrors).length >= 0 && + checkIfErrorsMatchElements(fieldErrors as any, elements) + ) { + hasErrors = true; + this.setState({fieldErrors}); + } + + if (formError) { + hasErrors = true; + this.setState({formError}); + if (this.scrollView?.current) { + this.scrollView.current.scrollTo({x: 0, y: 0}); + } + } + + return hasErrors; + } + + performLookup = async (name: string, userInput: string): Promise => { + const intl = this.context.intl; + const field = this.props.form.fields.find((f) => f.name === name); + if (!field) { + return []; + } + + 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({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error.', + }); + this.setState({ + fieldErrors: { + ...this.state.fieldErrors, + [field.name]: errMsg, + }, + }); + return []; + } + + const callResp = res.data!; + switch (callResp.type) { + case AppCallResponseTypes.OK: + return callResp.data?.items || []; + case AppCallResponseTypes.FORM: + case AppCallResponseTypes.NAVIGATE: { + const errMsg = intl.formatMessage({ + id: 'apps.error.responses.unexpected_type', + defaultMessage: 'App response type was not expected. Response type: {type}.', + }, { + type: callResp.type, + }, + ); + this.setState({ + fieldErrors: { + ...this.state.fieldErrors, + [field.name]: errMsg, + }, + }); + return []; + } + default: { + const errMsg = intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResp.type, + }, + ); + this.setState({ + fieldErrors: { + ...this.state.fieldErrors, + [field.name]: errMsg, + }, + }); + return []; + } + } + } + + handleHide = () => { + dismissModal(); + } + + onChange = (name: string, value: any) => { + const field = this.props.form.fields.find((f) => f.name === name); + if (!field) { + return; + } + + const values = {...this.state.values, [name]: value}; + + if (field.refresh) { + this.props.actions.refreshOnSelect(field, values, value).then((res) => { + if (res.error) { + const errorResponse = res.error; + const errorMsg = errorResponse.error; + const errors = errorResponse.data?.errors; + const elements = fieldsAsElements(this.props.form.fields); + this.updateErrors(elements, errors, errorMsg); + return; + } + + const callResponse = res.data!; + switch (callResponse.type) { + case AppCallResponseTypes.FORM: + return; + case AppCallResponseTypes.OK: + case AppCallResponseTypes.NAVIGATE: + this.updateErrors([], undefined, this.context.intl.formatMessage({ + id: 'apps.error.responses.unexpected_type', + defaultMessage: 'App response type was not expected. Response type: {type}.', + }, { + type: callResponse.type, + })); + return; + default: + this.updateErrors([], undefined, this.context.intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResponse.type, + })); + } + }); + } + + this.setState({values}); + }; + + render() { + const {theme, form} = this.props; + const {fields, header} = form; + const {formError, fieldErrors, values} = this.state; + const style = getStyleFromTheme(theme); + + return ( + + + + {formError && ( + + )} + {header && + + } + {fields && fields.filter((f) => f.name !== form.submit_buttons).map((field) => { + return ( + + ); + })} + + + ); + } +} + +function fieldsAsElements(fields?: AppField[]): DialogElement[] { + return fields?.map((f) => ({ + name: f.name, + type: f.type, + subtype: f.subtype, + optional: !f.is_required, + })) as DialogElement[]; +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), + }, + errorContainer: { + marginTop: 15, + marginLeft: 15, + fontSize: 14, + fontWeight: 'bold', + }, + scrollView: { + marginBottom: 20, + marginTop: 10, + }, + }; +}); diff --git a/app/screens/apps_form/apps_form_container.tsx b/app/screens/apps_form/apps_form_container.tsx new file mode 100644 index 000000000..97dd76766 --- /dev/null +++ b/app/screens/apps_form/apps_form_container.tsx @@ -0,0 +1,241 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import {intlShape} from 'react-intl'; + +import {Theme} from '@mm-redux/types/preferences'; +import {AppCallResponse, AppCallRequest, AppField, AppForm, AppFormValues, FormResponseData, AppCallType, AppLookupResponse} from '@mm-redux/types/apps'; +import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps'; +import AppsFormComponent from './apps_form_component'; +import {makeCallErrorResponse} from '@utils/apps'; +import {ActionResult} from '@mm-redux/types/actions'; + +export type Props = { + form?: AppForm; + call?: AppCallRequest; + actions: { + doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>; + sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise; + }; + theme: Theme; + componentId: string; +}; + +export type State = { + form?: AppForm; +} + +export default class AppsFormContainer extends PureComponent { + static contextTypes = { + intl: intlShape.isRequired, + }; + + constructor(props: Props) { + super(props); + this.state = {form: props.form}; + } + + handleSubmit = async (submission: {values: AppFormValues}): Promise<{data?: AppCallResponse, error?: AppCallResponse}> => { + const intl = this.context.intl; + const makeErrorMsg = (msg: string) => { + return intl.formatMessage( + { + id: 'apps.error.form.submit.pretext', + defaultMessage: 'There has been an error submitting the modal. Contact the app developer. Details: {details}', + }, + {details: msg}, + ); + }; + + const {form} = this.state; + if (!form) { + return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage( + { + id: 'apps.error.form.no_form', + defaultMessage: '`form` is not defined', + }, + )))}; + } + + const call = this.getCall(); + if (!call) { + return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage( + { + id: 'apps.error.form.no_call', + defaultMessage: '`call` is not defined', + }, + )))}; + } + + const res = await this.props.actions.doAppCall({ + ...call, + values: submission.values, + }, AppCallTypes.SUBMIT, intl); + + if (res.error) { + return res; + } + + const callResp = res.data!; + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.markdown) { + this.props.actions.sendEphemeralPost(callResp.markdown); + } + break; + case AppCallResponseTypes.FORM: + this.setState({form: callResp.form}); + break; + case AppCallResponseTypes.NAVIGATE: + break; + default: + return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage( + { + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResp.type, + }, + )))}; + } + return res; + } + + refreshOnSelect = async (field: AppField, values: AppFormValues): Promise<{data?: AppCallResponse, error?: AppCallResponse}> => { + const intl = this.context.intl; + const makeErrorMsg = (message: string) => intl.formatMessage( + { + id: 'apps.error.form.refresh', + defaultMessage: 'There has been an error updating the modal. Contact the app developer. Details: {details}', + }, + {details: message}, + ); + const {form} = this.state; + if (!form) { + return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({ + id: 'apps.error.form.no_form', + defaultMessage: '`form` is not defined.', + })))}; + } + + const call = this.getCall(); + if (!call) { + return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({ + id: 'apps.error.form.no_call', + defaultMessage: '`call` is not defined.', + })))}; + } + + if (!field.refresh) { + // Should never happen + return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({ + id: 'apps.error.form.refresh_no_refresh', + defaultMessage: 'Called refresh on no refresh field.', + })))}; + } + + const res = await this.props.actions.doAppCall({ + ...call, + selected_field: field.name, + values, + + }, AppCallTypes.FORM, intl); + + if (res.error) { + return res; + } + const callResp = res.data!; + switch (callResp.type) { + case AppCallResponseTypes.FORM: + this.setState({form: callResp.form}); + break; + case AppCallResponseTypes.OK: + case AppCallResponseTypes.NAVIGATE: + return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({ + id: 'apps.error.responses.unexpected_type', + defaultMessage: 'App response type was not expected. Response type: {type}.', + }, { + type: callResp.type, + }, + )))}; + default: + return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResp.type, + }, + )))}; + } + return res; + }; + + performLookupCall = async (field: AppField, values: AppFormValues, userInput: string): Promise<{data?: AppCallResponse, error?: AppCallResponse}> => { + const intl = this.context.intl; + const makeErrorMsg = (message: string) => intl.formatMessage( + { + id: 'apps.error.form.refresh', + defaultMessage: 'There has been an error fetching the select fields. Contact the app developer. Details: {details}', + }, + {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'})); + } + + 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; + } + + return { + ...call, + ...form?.call, + context: { + ...call.context, + }, + values: { + ...call.values, + }, + }; + } + + render() { + const {form} = this.state; + if (!form) { + return null; + } + + const call = this.getCall(); + if (!call) { + return null; + } + + return ( + + ); + } +} diff --git a/app/screens/apps_form/apps_form_field.tsx b/app/screens/apps_form/apps_form_field.tsx new file mode 100644 index 000000000..fcf43f18f --- /dev/null +++ b/app/screens/apps_form/apps_form_field.tsx @@ -0,0 +1,150 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import BoolSetting from '@components/widgets/settings/bool_setting'; +import TextSetting from '@components/widgets/settings/text_setting'; +import {ViewTypes} from '@constants/index'; +import {AppField, AppFormValue, AppSelectOption} from '@mm-redux/types/apps'; +import {Theme} from '@mm-redux/types/preferences'; +import React from 'react'; + +import AppFormSelector from './app_form_selector'; + +const TEXT_DEFAULT_MAX_LENGTH = 150; +const TEXTAREA_DEFAULT_MAX_LENGTH = 3000; + +export type Props = { + field: AppField; + name: string; + errorText?: React.ReactNode; + theme: Theme; + + value: AppFormValue; + onChange: (name: string, value: string | AppSelectOption) => void; + performLookup: (name: string, userInput: string) => Promise; +} + +export default class AppsFormField extends React.PureComponent { + render() { + const { + field, + name, + value, + onChange, + errorText, + theme, + } = this.props; + + const placeholder = field.hint || ''; + const displayName = (field.modal_label || field.label) as string; + + if (field.type === 'text') { + let keyboardType = 'default'; + let multiline = false; + let secureTextEntry = false; + + const subtype = field.subtype || 'text'; + + let maxLength = field.max_length; + if (!maxLength) { + if (subtype === 'textarea') { + maxLength = TEXTAREA_DEFAULT_MAX_LENGTH; + } else { + maxLength = TEXT_DEFAULT_MAX_LENGTH; + } + } + + let textType = 'input'; + if (subtype && TextSetting.validTypes.includes(subtype)) { + textType = subtype; + } + + switch (textType) { + case 'email': + keyboardType = 'email-address'; + break; + case 'number': + keyboardType = 'numeric'; + break; + case 'tel': + keyboardType = 'phone-pad'; + break; + case 'url': + keyboardType = 'url'; + break; + case 'password': + secureTextEntry = true; + break; + case 'textarea': + multiline = true; + break; + } + + const textValue = value as string; + return ( + + ); + } else if (field.type === 'channel' || field.type === 'user' || field.type === 'dynamic_select' || field.type === 'static_select') { + let dataSource = ViewTypes.DATA_SOURCE_CHANNELS; + if (field.type === 'user') { + dataSource = ViewTypes.DATA_SOURCE_USERS; + } + if (field.type === 'dynamic_select') { + dataSource = 'app'; + } + const option = value as AppSelectOption; + return ( + this.props.onChange(field.name, selected)} + helpText={field.description} + errorText={errorText} + placeholder={placeholder} + showRequiredAsterisk={true} + selected={option} + roundedBorders={false} + disabled={field.readonly} + performLookupCall={(term: string) => this.props.performLookup(field.name, term)} + /> + ); + } else if (field.type === 'bool') { + const boolValue = value as boolean; + return ( + + ); + } + + return null; + } +} diff --git a/app/screens/apps_form/dialog_introduction_text.test.tsx b/app/screens/apps_form/dialog_introduction_text.test.tsx new file mode 100644 index 000000000..8549d18f9 --- /dev/null +++ b/app/screens/apps_form/dialog_introduction_text.test.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import Preferences from '@mm-redux/constants/preferences'; + +import DialogIntroductionText from './dialog_introduction_text'; + +describe('DialogIntroductionText', () => { + const baseProps = { + theme: Preferences.THEMES.default, + value: '**bold** *italic* [link](https://mattermost.com/)
[link target blank](!https://mattermost.com/)', + }; + + test('should render the introduction text correctly', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should not render the component with an empty value', () => { + baseProps.value = ''; + + const wrapper = shallow( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); +}); diff --git a/app/screens/apps_form/dialog_introduction_text.tsx b/app/screens/apps_form/dialog_introduction_text.tsx new file mode 100644 index 000000000..4f352e8cd --- /dev/null +++ b/app/screens/apps_form/dialog_introduction_text.tsx @@ -0,0 +1,58 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import {View} from 'react-native'; + +import {Theme} from '@mm-redux/types/preferences'; +import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown'; +import Markdown from '@components/markdown'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + value: string; + theme: Theme; +} + +export default class DialogIntroductionText extends PureComponent { + render() { + const { + value, + theme, + } = this.props; + + if (value) { + const style = getStyleFromTheme(theme); + const blockStyles = getMarkdownBlockStyles(theme); + const textStyles = getMarkdownTextStyles(theme); + + return ( + + + + ); + } + + return null; + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + introductionTextView: { + marginHorizontal: 15, + }, + introductionText: { + color: theme.centerChannelColor, + }, + }; +}); diff --git a/app/screens/apps_form/index.ts b/app/screens/apps_form/index.ts new file mode 100644 index 000000000..d9ee80ef3 --- /dev/null +++ b/app/screens/apps_form/index.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; +import {connect} from 'react-redux'; + +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {doAppCall} from '@actions/apps'; + +import {AppCallResponse, AppCallRequest, AppCallType} from '@mm-redux/types/apps'; +import {GlobalState} from '@mm-redux/types/store'; +import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions'; + +import AppsFormContainer from './apps_form_container'; +import {sendEphemeralPost} from '@actions/views/post'; + +type Actions = { + doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>; + sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise; +}; + +function mapStateToProps(state: GlobalState) { + return { + theme: getTheme(state), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators, Actions>({ + doAppCall, + sendEphemeralPost, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(AppsFormContainer); diff --git a/app/screens/channel/channel_post_list/__snapshots__/channel_post_list.test.js.snap b/app/screens/channel/channel_post_list/__snapshots__/channel_post_list.test.js.snap index 321a3617c..5a103dfa3 100644 --- a/app/screens/channel/channel_post_list/__snapshots__/channel_post_list.test.js.snap +++ b/app/screens/channel/channel_post_list/__snapshots__/channel_post_list.test.js.snap @@ -27,6 +27,7 @@ exports[`ChannelPostList should match snapshot 1`] = ` indicateNewMessages={true} lastPostIndex={-1} loadMorePostsVisible={false} + location="channel" onLoadMoreUp={[Function]} onPostPress={[Function]} postIds={Array []} diff --git a/app/screens/channel/channel_post_list/channel_post_list.js b/app/screens/channel/channel_post_list/channel_post_list.js index 4d7aef47d..d05ea7684 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.js +++ b/app/screens/channel/channel_post_list/channel_post_list.js @@ -19,6 +19,7 @@ import {getLastPostIndex} from '@mm-redux/utils/post_list'; import tracker from '@utils/time_tracker'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import telemetry from '@telemetry'; +import {CHANNEL} from '@constants/screen'; let ChannelIntro = null; let LoadMorePosts = null; @@ -209,6 +210,7 @@ export default class ChannelPostList extends PureComponent { scrollViewNativeID={channelId} loadMorePostsVisible={this.props.loadMorePostsVisible} showMoreMessagesButton={true} + location={CHANNEL} /> ); } diff --git a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap index dbc61d281..201240039 100644 --- a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap +++ b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap @@ -503,6 +503,37 @@ exports[`channelInfo should match snapshot 1`] = ` } } /> +
+ `; @@ -1363,5 +1425,36 @@ exports[`channelInfo should not include NotificationPreference for direct messag } } /> + `; diff --git a/app/screens/channel_info/bindings/bindings.tsx b/app/screens/channel_info/bindings/bindings.tsx new file mode 100644 index 000000000..6669b5cba --- /dev/null +++ b/app/screens/channel_info/bindings/bindings.tsx @@ -0,0 +1,147 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Alert} from 'react-native'; +import {intlShape, injectIntl} from 'react-intl'; + +import Separator from '@screens/channel_info/separator'; + +import ChannelInfoRow from '../channel_info_row'; +import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps'; +import {Theme} from '@mm-redux/types/preferences'; +import {Channel} from '@mm-redux/types/channels'; +import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps'; +import {dismissModal} from '@actions/navigation'; +import {ActionResult} from '@mm-redux/types/actions'; +import {createCallContext, createCallRequest} from '@utils/apps'; + +type Props = { + bindings: AppBinding[]; + theme: Theme; + currentChannel: Channel; + appsEnabled: boolean; + currentTeamId: string; + actions: { + doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>; + sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise; + } +} + +const Bindings: React.FC = (props: Props) => { + if (!props.appsEnabled) { + return null; + } + + const {bindings, ...optionProps} = props; + if (bindings.length === 0) { + return null; + } + + const options = bindings.map((b) => ( + `; @@ -207,8 +219,8 @@ exports[`PostOptions should match snapshot, showing Delete option only for syste > + `; @@ -439,8 +463,8 @@ exports[`PostOptions should match snapshot, showing all possible options 1`] = ` > + `; diff --git a/app/screens/post_options/bindings/bindings.tsx b/app/screens/post_options/bindings/bindings.tsx new file mode 100644 index 000000000..7d379dc9d --- /dev/null +++ b/app/screens/post_options/bindings/bindings.tsx @@ -0,0 +1,159 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Alert} from 'react-native'; +import {intlShape, injectIntl} from 'react-intl'; + +import {isSystemMessage} from '@mm-redux/utils/post_utils'; + +import PostOption from '../post_option'; +import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps'; +import {Theme} from '@mm-redux/types/preferences'; +import {Post} from '@mm-redux/types/posts'; +import {UserProfile} from '@mm-redux/types/users'; +import {AppCallResponseTypes, AppCallTypes, AppExpandLevels} from '@mm-redux/constants/apps'; +import {ActionResult} from '@mm-redux/types/actions'; +import {createCallContext, createCallRequest} from '@utils/apps'; + +type Props = { + bindings: AppBinding[], + theme: Theme, + post: Post, + currentUser: UserProfile, + teamID: string, + closeWithAnimation: () => void, + appsEnabled: boolean, + actions: { + doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>; + sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise; + } +} + +const Bindings = (props: Props) => { + if (!props.appsEnabled) { + return null; + } + + const {bindings, post, ...optionProps} = props; + if (bindings.length === 0) { + return null; + } + + if (isSystemMessage(post)) { + return null; + } + + const options = bindings.map((b) => ( +