diff --git a/actions/apps.ts b/actions/apps.ts new file mode 100644 index 000000000000..5dbe2a81d885 --- /dev/null +++ b/actions/apps.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Client4} from 'mattermost-redux/client'; +import {Action, ActionFunc, DispatchFunc} from 'mattermost-redux/types/actions'; +import {AppCallResponse, AppForm, AppCallType, AppCallRequest} from 'mattermost-redux/types/apps'; +import {AppCallTypes, AppCallResponseTypes} from 'mattermost-redux/constants/apps'; + +import {openModal} from 'actions/views/modals'; + +import AppsForm from 'components/apps_form'; + +import {ModalIdentifiers} from 'utils/constants'; +import {getSiteURL, shouldOpenInNewTab} from 'utils/url'; +import {browserHistory} from 'utils/browser_history'; +import {makeCallErrorResponse} from 'utils/apps'; + +export function doAppCall(call: AppCallRequest, type: AppCallType, intl: any): ActionFunc { + return async (dispatch: DispatchFunc) => { + 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 {data: res}; + case AppCallResponseTypes.FORM: + if (!res.form) { + const errMsg = intl.formatMessage({ + id: 'apps.error.responses.form.no_form', + defaultMessage: 'Response type is `form`, but no form was included in response.', + }); + return {data: makeCallErrorResponse(errMsg)}; + } + + if (type === AppCallTypes.SUBMIT) { + dispatch(openAppsModal(res.form, call)); + } + + return {data: res}; + case AppCallResponseTypes.NAVIGATE: + if (!res.navigate_to_url) { + const errMsg = intl.formatMessage({ + id: 'apps.error.responses.navigate.no_url', + defaultMessage: 'Response type is `navigate`, but no url was included in response.', + }); + return {data: makeCallErrorResponse(errMsg)}; + } + + if (type !== AppCallTypes.SUBMIT) { + const errMsg = intl.formatMessage({ + id: 'apps.error.responses.navigate.no_submit', + defaultMessage: 'Response type is `navigate`, but the call was not a submission.', + }); + return {data: makeCallErrorResponse(errMsg)}; + } + + if (shouldOpenInNewTab(res.navigate_to_url, getSiteURL())) { + window.open(res.navigate_to_url); + return {data: res}; + } + + browserHistory.push(res.navigate_to_url.slice(getSiteURL().length)); + return {data: res}; + default: { + const errMsg = intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, {type: responseType}); + return {data: makeCallErrorResponse(errMsg)}; + } + } + } catch (error) { + const errMsg = error.message || intl.formatMessage({ + id: 'apps.error.responses.unexpected_error', + defaultMessage: 'Received an unexpected error.', + }); + return {data: makeCallErrorResponse(errMsg)}; + } + }; +} + +export function openAppsModal(form: AppForm, call: AppCallRequest): Action { + return openModal({ + modalId: ModalIdentifiers.APPS_MODAL, + dialogType: AppsForm, + dialogProps: { + form, + call, + }, + }); +} diff --git a/actions/command.test.js b/actions/command.test.js index bae72c4f65c4..960749c11ff6 100644 --- a/actions/command.test.js +++ b/actions/command.test.js @@ -10,6 +10,8 @@ import {Client4} from 'mattermost-redux/client'; import * as Channels from 'mattermost-redux/selectors/entities/channels'; import * as Teams from 'mattermost-redux/selectors/entities/teams'; +import {AppCallResponseTypes} from 'mattermost-redux/constants/apps'; + import {ActionTypes, Constants} from 'utils/constants'; import * as UserAgent from 'utils/user_agent'; import * as GlobalActions from 'actions/global_actions'; @@ -24,14 +26,29 @@ const currentTeamId = '321'; const currentUserId = 'user123'; const initialState = { entities: { + admin: { + pluginStatuses: { + 'com.mattermost.apps': { + state: 2, + }, + }, + }, general: { config: { ExperimentalViewArchivedChannels: 'false', EnableLegacySidebar: 'true', }, }, + posts: { + posts: { + root_id: {id: 'root_id', channel_id: '123'}, + }, + }, channels: { currentChannelId, + channels: { + 123: {id: '123', team_id: '456'}, + }, }, preferences: { myPreferences: {}, @@ -51,6 +68,46 @@ const initialState = { }, }, }, + apps: { + bindings: [{ + location: '/command', + bindings: [{ + app_id: 'appid', + label: 'appid', + bindings: [ + { + app_id: 'appid', + label: 'custom', + description: 'Run the command.', + call: { + path: 'https://someserver.com/command', + }, + form: { + fields: [ + { + name: 'key1', + label: 'key1', + type: 'text', + position: 1, + }, + { + name: 'key2', + label: 'key2', + type: 'static_select', + options: [ + { + label: 'Value 2', + value: 'value2', + }, + ], + }, + ], + }, + }, + ], + }], + }], + }, }, views: { rhs: { @@ -177,4 +234,52 @@ describe('executeCommand', () => { expect(result).toEqual({data: true}); }); }); + + describe('app command', () => { + test('should call executeAppCall', async () => { + const state = { + ...initialState, + entities: { + ...initialState.entities, + general: { + ...initialState.entities.general, + config: { + ...initialState.entities.general.config, + FeatureFlagAppsEnabled: 'true', + }, + }, + }, + }; + store = await mockStore(state); + const f = Client4.executeAppCall; + const mocked = jest.fn().mockResolvedValue(Promise.resolve({ + type: AppCallResponseTypes.OK, + markdown: 'Success', + })); + Client4.executeAppCall = mocked; + + const result = await store.dispatch(executeCommand('/appid custom value1 --key2 value2', {channel_id: '123', root_id: 'root_id'})); + Client4.executeAppCall = f; + + expect(mocked).toHaveBeenCalledWith({ + context: { + app_id: 'appid', + channel_id: '123', + location: '/command', + root_id: 'root_id', + team_id: '456', + }, + raw_command: '/appid custom value1 --key2 value2', + path: 'https://someserver.com/command', + values: { + key1: 'value1', + key2: {label: 'Value 2', value: 'value2'}, + }, + expand: {}, + query: undefined, + selected_field: undefined, + }, 'submit'); + expect(result).toEqual({data: true}); + }); + }); }); diff --git a/actions/command.js b/actions/command.ts similarity index 61% rename from actions/command.js rename to actions/command.ts index a073178ac1d3..62219dcdac9e 100644 --- a/actions/command.js +++ b/actions/command.ts @@ -7,23 +7,38 @@ import {savePreferences} from 'mattermost-redux/actions/preferences'; import {getCurrentChannel, getRedirectChannelNameForTeam, isFavoriteChannel} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getCurrentRelativeTeamUrl, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {IntegrationTypes} from 'mattermost-redux/action_types'; +import {ActionFunc, DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; +import type {CommandArgs} from 'mattermost-redux/types/integrations'; + +import {AppCallResponseTypes, AppCallTypes} from 'mattermost-redux/constants/apps'; + +import {AppCallResponse} from 'mattermost-redux/types/apps'; import {openModal} from 'actions/views/modals'; import * as GlobalActions from 'actions/global_actions'; import * as PostActions from 'actions/post_actions.jsx'; import {isUrlSafe, getSiteURL} from 'utils/url'; -import {localizeMessage, getUserIdFromChannelName} from 'utils/utils.jsx'; +import {localizeMessage, getUserIdFromChannelName, localizeAndFormatMessage} from 'utils/utils.jsx'; import * as UserAgent from 'utils/user_agent'; import {Constants, ModalIdentifiers} from 'utils/constants'; import {browserHistory} from 'utils/browser_history'; import UserSettingsModal from 'components/user_settings/modal'; +import {AppCommandParser} from 'components/suggestion/command_provider/app_command_parser/app_command_parser'; +import {intlShim} from 'components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies'; + +import {GlobalState} from 'types/store'; + +import {t} from 'utils/i18n'; + +import {doAppCall} from './apps'; -export function executeCommand(message, args) { - return async (dispatch, getState) => { - const state = getState(); +export function executeCommand(message: string, args: CommandArgs): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState() as GlobalState; let msg = message; @@ -47,7 +62,7 @@ export function executeCommand(message, args) { GlobalActions.toggleShortcutsModal(); return {data: true}; case '/leave': { - // /leave command not supported in reply threads. + // /leave command not supported in reply threads. if (args.channel_id && (args.root_id || args.parent_id)) { GlobalActions.sendEphemeralPost('/leave is not supported in reply threads. Use it in the center channel instead.', args.channel_id, args.parent_id); return {data: true}; @@ -93,6 +108,46 @@ export function executeCommand(message, args) { dispatch(PostActions.resetEmbedVisibility()); } + if (appsEnabled(state)) { + const getGlobalState = () => getState() as GlobalState; + const createErrorMessage = (errMessage: string) => { + return {error: {message: errMessage}}; + }; + const parser = new AppCommandParser({dispatch, getState: getGlobalState} as any, intlShim, args.channel_id, args.root_id); + if (parser.isAppCommand(msg)) { + try { + const call = await parser.composeCallFromCommand(msg); + if (!call) { + return createErrorMessage(localizeMessage('apps.error.commands.compose_call', 'Error composing command submission')); + } + + const res = await dispatch(doAppCall(call, AppCallTypes.SUBMIT, intlShim)) as {data: AppCallResponse}; + + const callResp = res.data; + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.markdown) { + GlobalActions.sendEphemeralPost(callResp.markdown, args.channel_id, args.parent_id); + } + return {data: true}; + case AppCallResponseTypes.ERROR: + return createErrorMessage(callResp.error || localizeMessage('apps.error.unknown', 'Unknown error.')); + case AppCallResponseTypes.FORM: + case AppCallResponseTypes.NAVIGATE: + return {data: true}; + default: + return createErrorMessage(localizeAndFormatMessage( + t('apps.error.responses.unknown_type'), + 'App response type not supported. Response type: {type}.', + {type: callResp.type}, + )); + } + } catch (err) { + return createErrorMessage(err.message || localizeMessage('apps.error.unknown', 'Unknown error.')); + } + } + } + let data; try { data = await Client4.executeCommand(msg, args); diff --git a/actions/global_actions.tsx b/actions/global_actions.tsx index ca022a526ec4..ad1780dc66c2 100644 --- a/actions/global_actions.tsx +++ b/actions/global_actions.tsx @@ -15,7 +15,9 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId, getMyTeams, getTeam, getMyTeamMember, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUser, getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getCurrentChannelStats, getCurrentChannelId, getMyChannelMember, getRedirectChannelNameForTeam, getChannelsNameMapInTeam, getAllDirectChannels} from 'mattermost-redux/selectors/entities/channels'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {ChannelTypes} from 'mattermost-redux/action_types'; +import {fetchAppBindings} from 'mattermost-redux/actions/apps'; import {Channel, ChannelMembership} from 'mattermost-redux/types/channels'; import {UserProfile} from 'mattermost-redux/types/users'; import {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; @@ -94,6 +96,10 @@ export function emitChannelClickEvent(channel: Channel) { channel: chan, member: member || {}, }])); + + if (appsEnabled(state)) { + dispatch(fetchAppBindings(userId, chan.id)); + } } if (channel.fake) { @@ -180,7 +186,7 @@ export function showMobileSubMenuModal(elements: any[]) { // TODO Use more speci dispatch(openModal(submenuModalData)); } -export function sendEphemeralPost(message: string, channelId: string, parentId: string) { +export function sendEphemeralPost(message: string, channelId?: string, parentId?: string): void { const timestamp = Utils.getTimestamp(); const post = { id: Utils.generateId(), diff --git a/actions/marketplace.ts b/actions/marketplace.ts index 74d2ade87647..def7d48b9fda 100644 --- a/actions/marketplace.ts +++ b/actions/marketplace.ts @@ -3,33 +3,77 @@ import {Client4} from 'mattermost-redux/client'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; + import {ActionFunc, DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; +import type {MarketplaceApp, MarketplacePlugin} from 'mattermost-redux/types/marketplace'; +import type {CommandArgs} from 'mattermost-redux/types/integrations'; + +import {GlobalState} from 'types/store'; -import {getFilter, getPlugin} from 'selectors/views/marketplace'; +import {getApp, getFilter, getPlugin} from 'selectors/views/marketplace'; import {ActionTypes} from 'utils/constants'; -// fetchPlugins fetches the latest marketplace plugins, subject to any existing search filter. -export function fetchPlugins(localOnly = false): ActionFunc { +import {isError} from 'types/actions'; + +import {executeCommand} from './command'; + +// fetchPlugins fetches the latest marketplace plugins and apps, subject to any existing search filter. +export function fetchListing(localOnly = false): ActionFunc { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); + const state = getState() as GlobalState; const filter = getFilter(state); - try { - const plugins = await Client4.getMarketplacePlugins(filter, localOnly); + let plugins: MarketplacePlugin[]; + let apps: MarketplaceApp[] = []; - dispatch({ - type: ActionTypes.RECEIVED_MARKETPLACE_PLUGINS, - plugins, - }); - - return {data: plugins}; + try { + plugins = await Client4.getMarketplacePlugins(filter, localOnly); } catch (error) { // If the marketplace server is unreachable, try to get the local plugins only. if (error.server_error_id === 'app.plugin.marketplace_client.failed_to_fetch' && !localOnly) { - await dispatch(fetchPlugins(true)); + await dispatch(fetchListing(true)); } return {error}; } + + dispatch({ + type: ActionTypes.RECEIVED_MARKETPLACE_PLUGINS, + plugins, + }); + + if (appsEnabled(state)) { + try { + apps = await Client4.getMarketplaceApps(filter); + } catch (error) { + return {data: plugins}; + } + + dispatch({ + type: ActionTypes.RECEIVED_MARKETPLACE_APPS, + apps, + }); + } + + if (plugins) { + return {data: (plugins as Array).concat(apps)}; + } + + return {data: apps}; + }; +} + +// filterListing sets a search filter for marketplace listing, fetching the latest data. +export function filterListing(filter: string): ActionFunc { + return async (dispatch: DispatchFunc) => { + dispatch({ + type: ActionTypes.FILTER_MARKETPLACE_LISTING, + filter, + }); + + return dispatch(fetchListing()); }; } @@ -37,18 +81,18 @@ export function fetchPlugins(localOnly = false): ActionFunc { // // On success, it also requests the current state of the plugins to reflect the newly installed plugin. export function installPlugin(id: string, version: string) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { dispatch({ - type: ActionTypes.INSTALLING_MARKETPLACE_PLUGIN, + type: ActionTypes.INSTALLING_MARKETPLACE_ITEM, id, }); - const state = getState(); + const state = getState() as GlobalState; const marketplacePlugin = getPlugin(state, id); if (!marketplacePlugin) { dispatch({ - type: ActionTypes.INSTALLING_MARKETPLACE_PLUGIN_FAILED, + type: ActionTypes.INSTALLING_MARKETPLACE_ITEM_FAILED, id, error: 'Unknown plugin: ' + id, }); @@ -59,29 +103,65 @@ export function installPlugin(id: string, version: string) { await Client4.installMarketplacePlugin(id, version); } catch (error) { dispatch({ - type: ActionTypes.INSTALLING_MARKETPLACE_PLUGIN_FAILED, + type: ActionTypes.INSTALLING_MARKETPLACE_ITEM_FAILED, id, error: error.message, }); return; } - await dispatch(fetchPlugins()); + await dispatch(fetchListing()); dispatch({ - type: ActionTypes.INSTALLING_MARKETPLACE_PLUGIN_SUCCEEDED, + type: ActionTypes.INSTALLING_MARKETPLACE_ITEM_SUCCEEDED, id, }); }; } -// filterPlugins sets a search filter for marketplace plugins, fetching the latest data. -export function filterPlugins(filter: string): ActionFunc { - return async (dispatch: DispatchFunc) => { +// installApp installed an App using a given URL via the /apps install slash command. +// +// On success, it also requests the current state of the plugins to reflect the newly installed plugin. +export function installApp(id: string) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { dispatch({ - type: ActionTypes.FILTER_MARKETPLACE_PLUGINS, - filter, + type: ActionTypes.INSTALLING_MARKETPLACE_ITEM, + id, }); - return dispatch(fetchPlugins()); + const state = getState() as GlobalState; + + const channelID = getCurrentChannelId(state); + const teamID = getCurrentTeamId(state); + + const app = getApp(state, id); + if (!app) { + dispatch({ + type: ActionTypes.INSTALLING_MARKETPLACE_ITEM_FAILED, + id, + error: 'Unknown app: ' + id, + }); + return false; + } + + const args: CommandArgs = { + channel_id: channelID, + team_id: teamID, + }; + + const result = await dispatch(executeCommand('/apps install --app-id ' + id, args)); + if (isError(result)) { + dispatch({ + type: ActionTypes.INSTALLING_MARKETPLACE_ITEM_FAILED, + id, + error: result.error.message, + }); + return false; + } + + dispatch({ + type: ActionTypes.INSTALLING_MARKETPLACE_ITEM_SUCCEEDED, + id, + }); + return true; }; } diff --git a/actions/post_actions.jsx b/actions/post_actions.jsx index 80365444c6ec..ac1c84708095 100644 --- a/actions/post_actions.jsx +++ b/actions/post_actions.jsx @@ -185,6 +185,7 @@ export function pinPost(postId) { if (rhsState === RHSStates.PIN) { addPostToSearchResults(postId, state, dispatch); } + return {data: true}; }; } @@ -197,6 +198,7 @@ export function unpinPost(postId) { if (rhsState === RHSStates.PIN) { removePostFromSearchResults(postId, state, dispatch); } + return {data: true}; }; } @@ -235,6 +237,7 @@ export function markPostAsUnread(post) { const state = getState(); const userId = getCurrentUserId(state); await dispatch(PostActions.setUnreadPost(userId, post.id)); + return {data: true}; }; } diff --git a/actions/websocket_actions.jsx b/actions/websocket_actions.jsx index be3bcef36e20..bafc67e263c5 100644 --- a/actions/websocket_actions.jsx +++ b/actions/websocket_actions.jsx @@ -58,8 +58,11 @@ import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general import {getChannelsInTeam, getChannel, getCurrentChannel, getCurrentChannelId, getRedirectChannelNameForTeam, getMembersInCurrentChannel, getChannelMembersInChannels} from 'mattermost-redux/selectors/entities/channels'; import {getPost, getMostRecentPostIdInChannel} from 'mattermost-redux/selectors/entities/posts'; import {haveISystemPermission, haveITeamPermission} from 'mattermost-redux/selectors/entities/roles'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {getStandardAnalytics} from 'mattermost-redux/actions/admin'; +import {fetchAppBindings} from 'mattermost-redux/actions/apps'; + import {getSelectedChannelId} from 'selectors/rhs'; import {openModal} from 'actions/views/modals'; @@ -180,6 +183,7 @@ export function reconnect(includeWebSocket = true) { const mostRecentId = getMostRecentPostIdInChannel(state, currentChannelId); const mostRecentPost = getPost(state, mostRecentId); dispatch(loadChannelsForCurrentUser()); + dispatch(handleRefreshAppsBindings()); if (mostRecentPost) { dispatch(syncPostsInChannel(currentChannelId, mostRecentPost.create_at)); } else { @@ -485,6 +489,10 @@ export function handleEvent(msg) { handleFirstAdminVisitMarketplaceStatusReceivedEvent(msg); break; + case SocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: { + dispatch(handleRefreshAppsBindings(msg)); + break; + } default: } @@ -1363,7 +1371,17 @@ function handleCloudPaymentStatusUpdated() { return (doDispatch) => doDispatch(getCloudSubscription()); } +function handleRefreshAppsBindings() { + return (doDispatch, doGetState) => { + const state = doGetState(); + if (appsEnabled(state)) { + doDispatch(fetchAppBindings(getCurrentUserId(state), getCurrentChannelId(state))); + } + return {data: true}; + }; +} + function handleFirstAdminVisitMarketplaceStatusReceivedEvent(msg) { - var receivedData = JSON.parse(msg.data.firstAdminVisitMarketplaceStatus); + const receivedData = JSON.parse(msg.data.firstAdminVisitMarketplaceStatus); store.dispatch({type: GeneralTypes.FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED, data: receivedData}); } diff --git a/components/admin_console/custom_plugin_settings/index.js b/components/admin_console/custom_plugin_settings/index.js index bc98b96a6a56..1c49bf567824 100644 --- a/components/admin_console/custom_plugin_settings/index.js +++ b/components/admin_console/custom_plugin_settings/index.js @@ -5,6 +5,7 @@ import {connect} from 'react-redux'; import {createSelector} from 'reselect'; import {getRoles} from 'mattermost-redux/selectors/entities/roles'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {Constants} from 'utils/constants'; import {localizeMessage} from 'utils/utils.jsx'; @@ -13,6 +14,8 @@ import {getAdminConsoleCustomComponents} from 'selectors/admin_console'; import SchemaAdminSettings from '../schema_admin_settings'; import {it} from '../admin_definition'; +import {appsPluginID} from 'utils/apps'; + import CustomPluginSettings from './custom_plugin_settings.jsx'; import getEnablePluginSetting from './enable_plugin_setting'; @@ -20,7 +23,8 @@ function makeGetPluginSchema() { return createSelector( (state, pluginId) => state.entities.admin.plugins[pluginId], (state, pluginId) => getAdminConsoleCustomComponents(state, pluginId), - (plugin, customComponents) => { + (state) => appsEnabled(state), + (plugin, customComponents, areAppsEnabled) => { if (!plugin) { return null; } @@ -64,9 +68,11 @@ function makeGetPluginSchema() { }); } - const pluginEnableSetting = getEnablePluginSetting(plugin); - pluginEnableSetting.isDisabled = it.any(pluginEnableSetting.isDisabled, it.not(it.userHasWritePermissionOnResource('plugins'))); - settings.unshift(pluginEnableSetting); + if (plugin.id !== appsPluginID || areAppsEnabled) { + const pluginEnableSetting = getEnablePluginSetting(plugin); + pluginEnableSetting.isDisabled = it.any(pluginEnableSetting.isDisabled, it.not(it.userHasWritePermissionOnResource('plugins'))); + settings.unshift(pluginEnableSetting); + } settings.forEach((s) => { s.isDisabled = it.any(s.isDisabled, it.not(it.userHasWritePermissionOnResource('plugins'))); diff --git a/components/admin_console/plugin_management/__snapshots__/plugin_management.test.tsx.snap b/components/admin_console/plugin_management/__snapshots__/plugin_management.test.tsx.snap index 789a2efa78e6..7c6432d24532 100644 --- a/components/admin_console/plugin_management/__snapshots__/plugin_management.test.tsx.snap +++ b/components/admin_console/plugin_management/__snapshots__/plugin_management.test.tsx.snap @@ -3358,6 +3358,7 @@ exports[`components/PluginManagement should match snapshot, with installed plugi className="alert alert-transparent" > { webapp: {}, }, }, + appsEnabled: false, actions: { uploadPlugin: jest.fn(), installPluginFromUrl: jest.fn(), @@ -232,6 +233,7 @@ describe('components/PluginManagement', () => { }, pluginStatuses: {}, plugins: {}, + appsEnabled: false, actions: { uploadPlugin: jest.fn(), installPluginFromUrl: jest.fn(), @@ -321,6 +323,7 @@ describe('components/PluginManagement', () => { webapp: {}, }, }, + appsEnabled: false, actions: { uploadPlugin: jest.fn(), installPluginFromUrl: jest.fn(), @@ -377,6 +380,7 @@ describe('components/PluginManagement', () => { webapp: {}, }, }, + appsEnabled: false, actions: { uploadPlugin: jest.fn(), installPluginFromUrl: jest.fn(), @@ -433,6 +437,7 @@ describe('components/PluginManagement', () => { webapp: {}, }, }, + appsEnabled: false, actions: { uploadPlugin: jest.fn(), installPluginFromUrl: jest.fn(), @@ -491,6 +496,7 @@ describe('components/PluginManagement', () => { webapp: {}, }, }, + appsEnabled: false, actions: { uploadPlugin: jest.fn(), installPluginFromUrl: jest.fn(), diff --git a/components/admin_console/plugin_management/plugin_management.tsx b/components/admin_console/plugin_management/plugin_management.tsx index 4bae5f2baea9..908c1e16fc30 100644 --- a/components/admin_console/plugin_management/plugin_management.tsx +++ b/components/admin_console/plugin_management/plugin_management.tsx @@ -19,6 +19,7 @@ import AdminSettings, {BaseProps, BaseState} from '../admin_settings'; import BooleanSetting from '../boolean_setting'; import SettingsGroup from '../settings_group.jsx'; import TextSetting from '../text_setting'; +import {appsPluginID} from 'utils/apps'; const PluginItemState = ({state}: {state: number}) => { switch (state) { @@ -164,6 +165,7 @@ type PluginItemProps = { handleRemove: (e: any) => any; showInstances: boolean; hasSettings: boolean; + appsEnabled: boolean; isDisabled?: boolean; }; @@ -175,9 +177,10 @@ const PluginItem = ({ handleRemove, showInstances, hasSettings, + appsEnabled, isDisabled, }: PluginItemProps) => { - let activateButton; + let activateButton: React.ReactNode; const activating = pluginStatus.state === PluginState.PLUGIN_STATE_STARTING; const deactivating = pluginStatus.state === PluginState.PLUGIN_STATE_STOPPING; @@ -254,7 +257,7 @@ const PluginItem = ({ /> ); } - const removeButton = ( + let removeButton: React.ReactNode = ( {' - '} {'Plugin disabled by feature flag'}); + removeButton = null; + } + return (
@@ -391,6 +399,7 @@ type Props = BaseProps & { config: DeepPartial; pluginStatuses: Record; plugins: any; + appsEnabled: boolean; actions: { uploadPlugin: (fileData: File, force: boolean) => any; removePlugin: (pluginId: string) => any; @@ -923,6 +932,7 @@ export default class PluginManagement extends AdminSettings { handleRemove={this.showRemovePluginModal} showInstances={showInstances} hasSettings={hasSettings} + appsEnabled={this.props.appsEnabled} isDisabled={this.props.isDisabled} /> ); diff --git a/components/apps_form/__snapshots__/apps_form_container.test.jsx.snap b/components/apps_form/__snapshots__/apps_form_container.test.jsx.snap new file mode 100644 index 000000000000..aefd297b63a8 --- /dev/null +++ b/components/apps_form/__snapshots__/apps_form_container.test.jsx.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/apps_form/AppsFormContainer should match snapshot 1`] = ` + +`; diff --git a/components/apps_form/__snapshots__/apps_form_header.test.tsx.snap b/components/apps_form/__snapshots__/apps_form_header.test.tsx.snap new file mode 100644 index 000000000000..aed1c0d00308 --- /dev/null +++ b/components/apps_form/__snapshots__/apps_form_header.test.tsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/apps_form/AppsFormHeader should not fail on empty value 1`] = ` + +`; + +exports[`components/apps_form/AppsFormHeader should render message with supported values 1`] = ` + +`; diff --git a/components/apps_form/apps_form.test.jsx b/components/apps_form/apps_form.test.jsx new file mode 100644 index 000000000000..c5f396adaf3f --- /dev/null +++ b/components/apps_form/apps_form.test.jsx @@ -0,0 +1,123 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Modal} from 'react-bootstrap'; +import {Provider} from 'react-redux'; +import configureStore from 'redux-mock-store'; + +import {AppCallResponseTypes} from 'mattermost-redux/constants/apps'; + +import EmojiMap from 'utils/emoji_map'; + +import {mountWithIntl, shallowWithIntl} from 'tests/helpers/intl-test-helper'; + +import AppsForm from './apps_form'; + +describe('components/apps_form/AppsForm', () => { + const baseProps = { + form: { + fields: [{ + name: 'field1', + type: 'text', + }], + }, + call: { + path: '/submit_url', + }, + onHide: () => {}, + actions: { + performLookupCall: jest.fn(), + refreshOnSelect: jest.fn(), + submit: jest.fn().mockResolvedValue({ + data: { + type: 'ok', + }, + }), + }, + emojiMap: new EmojiMap(new Map()), + }; + + describe('generic error message', () => { + test('should appear when submit returns an error', async () => { + const props = { + ...baseProps, + actions: { + ...baseProps.actions, + submit: jest.fn().mockResolvedValue({ + data: {error: 'This is an error.', type: AppCallResponseTypes.ERROR}, + }), + }, + }; + const wrapper = shallowWithIntl(); + + await wrapper.instance().handleSubmit({preventDefault: jest.fn()}); + + const expected = ( +
+ {'This is an error.'} +
+ ); + expect(wrapper.find(Modal.Footer).containsMatchingElement(expected)).toBe(true); + }); + + test('should not appear when submit does not return an error', async () => { + const wrapper = shallowWithIntl(); + await wrapper.instance().handleSubmit({preventDefault: jest.fn()}); + + expect(wrapper.find(Modal.Footer).exists('.error-text')).toBe(false); + }); + }); + + describe('default select element', () => { + const mockStore = configureStore(); + + test('should be enabled by default', () => { + const selectField = { + type: 'static_select', + value: {label: 'Option3', value: 'opt3'}, + modal_label: 'Option Selector', + name: 'someoptionselector', + is_required: true, + options: [ + {label: 'Option1', value: 'opt1'}, + {label: 'Option2', value: 'opt2'}, + {label: 'Option3', value: 'opt3'}, + ], + min_length: 2, + max_length: 1024, + hint: '', + subtype: '', + description: '', + }; + + const fields = [selectField]; + const props = { + ...baseProps, + call: {}, + form: { + fields, + }, + }; + + const state = { + entities: { + general: { + config: {}, + }, + preferences: { + myPreferences: {}, + }, + }, + }; + + const store = mockStore(state); + const wrapper = mountWithIntl( + + + , + ); + expect(wrapper.find(Modal.Body).find('.react-select__single-value').text()).toEqual('Option3'); + }); + }); +}); diff --git a/components/apps_form/apps_form.tsx b/components/apps_form/apps_form.tsx new file mode 100644 index 000000000000..b11c59b1f9d5 --- /dev/null +++ b/components/apps_form/apps_form.tsx @@ -0,0 +1,438 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Modal} from 'react-bootstrap'; +import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; + +import { + checkDialogElementForError, checkIfErrorsMatchElements, +} from 'mattermost-redux/utils/integration_utils'; +import {AppCallResponse, AppField, AppForm, AppFormValues, AppSelectOption, AppCall} from 'mattermost-redux/types/apps'; +import {DialogElement} from 'mattermost-redux/types/integrations'; +import {AppCallResponseTypes} from 'mattermost-redux/constants/apps'; + +import SpinnerButton from 'components/spinner_button'; +import SuggestionList from 'components/suggestion/suggestion_list'; +import ModalSuggestionList from 'components/suggestion/modal_suggestion_list'; + +import {localizeMessage} from 'utils/utils.jsx'; + +import AppsFormField from './apps_form_field'; +import AppsFormHeader from './apps_form_header'; + +export type AppsFormProps = { + call: AppCall; + form: AppForm; + isEmbedded?: boolean; + onHide: () => void; + actions: { + submit: (submission: { + values: { + [name: string]: string; + }; + }) => Promise<{data: AppCallResponse}>; + performLookupCall: (field: AppField, values: AppFormValues, userInput: string) => Promise; + refreshOnSelect: (field: AppField, values: AppFormValues) => Promise<{data: AppCallResponse}>; + }; +} + +type Props = AppsFormProps & WrappedComponentProps<'intl'>; + +type FormResponseData = { + errors: { + [field: string]: string; + }; +} + +type State = { + show: boolean; + values: {[name: string]: string}; + error: string | null; + errors: {[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 class AppsForm extends React.PureComponent { + constructor(props: Props) { + super(props); + + const {form} = props; + const values = initFormValues(form); + + this.state = { + show: true, + values, + error: null, + errors: {}, + submitting: false, + form, + }; + } + + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + if (nextProps.form !== prevState.form) { + return { + values: initFormValues(nextProps.form), + form: nextProps.form, + }; + } + + return null; + } + + handleSubmit = async (e: React.FormEvent, submitName?: string, value?: string) => { + e.preventDefault(); + + const {fields} = this.props.form; + const values = this.state.values; + if (submitName && value) { + values[submitName] = value; + } + + const errors: {[name: string]: React.ReactNode} = {}; + if (fields) { + fields.forEach((field) => { + const element = { + name: field.name, + type: field.type, + subtype: field.subtype, + optional: !field.is_required, + } as DialogElement; + const error = checkDialogElementForError( // TODO: make sure all required values are present in `element` + element, + values[field.name], + ); + if (error) { + errors[field.name] = ( + + ); + } + }); + } + + this.setState({errors}); + + if (Object.keys(errors).length !== 0) { + return; + } + + const submission = { + values, + }; + + this.setState({submitting: true}); + + const res = await this.props.actions.submit(submission); + const callResp = res.data as AppCallResponse; + + this.setState({submitting: false}); + + let hasErrors = false; + let updatedForm = false; + switch (callResp.type) { + case AppCallResponseTypes.ERROR: { + if (callResp.error) { + hasErrors = true; + this.setState({error: callResp.error}); + } + + const newErrors = callResp.data?.errors; + + const elements = fields.map((field) => ({name: field.name})) as DialogElement[]; + if ( + newErrors && + Object.keys(newErrors).length >= 0 && + checkIfErrorsMatchElements(newErrors as any, elements) // TODO fix types on redux + ) { + hasErrors = true; + this.setState({errors: newErrors}); + } + break; + } + case AppCallResponseTypes.FORM: + updatedForm = true; + break; + case AppCallResponseTypes.OK: + case AppCallResponseTypes.NAVIGATE: + break; + default: + hasErrors = true; + this.setState({error: this.props.intl.formatMessage( + {id: 'apps.error.responses.unknown_type', defaultMessage: 'App response type not supported. Response type: {type}.'}, + {type: callResp.type}, + )}); + } + + if (!hasErrors && !updatedForm) { + this.handleHide(true); + } + }; + + performLookup = async (name: string, userInput: string): Promise => { + const field = this.props.form.fields.find((f) => f.name === name); + if (!field) { + return []; + } + + return this.props.actions.performLookupCall(field, this.state.values, userInput); + } + + onHide = () => { + this.handleHide(false); + }; + + handleHide = (submitted = false) => { + const {form} = this.props; + + if (!submitted && form.submit_on_cancel) { + // const dialog = { + // url, + // callback_id: callbackId, + // state, + // cancelled: true, + // }; + + // this.props.actions.submit(dialog); + } + + this.setState({show: false}); + }; + + 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); + } + + this.setState({values}); + }; + + renderModal() { + const {fields, header} = this.props.form; + + return ( + +
this.handleSubmit(e)}> + + + {this.renderHeader()} + + + {(fields || header) && ( + + {this.renderBody()} + + )} + + {this.renderFooter()} + +
+
+ ); + } + + renderEmbedded() { + const {fields, header} = this.props.form; + + return ( +
+
+ {this.renderHeader()} +
+ {(fields || header) && ( +
+ {this.renderBody()} +
+ )} +
+ {this.renderFooter()} +
+
+ ); + } + + renderHeader() { + const { + title, + icon, + } = this.props.form; + + let iconComponent; + if (icon) { + iconComponent = ( + {'modal + ); + } + + return ( + + {iconComponent} + {title} + + ); + } + + renderElements() { + const {isEmbedded, form} = this.props; + + const {fields} = form; + if (!fields) { + return null; + } + + return fields.filter((f) => f.name !== form.submit_buttons).map((field, index) => { + return ( + + ); + }); + } + + renderBody() { + const {fields, header} = this.props.form; + + return (fields || header) && ( + + {header && ( + + )} + {this.renderElements()} + + ); + } + + renderFooter() { + const {fields} = this.props.form; + + const submitText: React.ReactNode = ( + + ); + + let submitButtons = [( + + {submitText} + + )]; + + if (this.props.form.submit_buttons) { + const field = fields?.find((f) => f.name === this.props.form.submit_buttons); + if (field) { + const buttons = field.options?.map((o) => ( + this.handleSubmit(e, field.name, o.value)} + > + {o.label} + + )); + if (buttons) { + submitButtons = buttons; + } + } + } + + return ( + + {this.state.error && ( +
{this.state.error}
+ )} + + {submitButtons} +
+ ); + } + + render() { + return this.props.isEmbedded ? this.renderEmbedded() : this.renderModal(); + } +} + +export default injectIntl(AppsForm); diff --git a/components/apps_form/apps_form_container.test.jsx b/components/apps_form/apps_form_container.test.jsx new file mode 100644 index 000000000000..5286a1916d16 --- /dev/null +++ b/components/apps_form/apps_form_container.test.jsx @@ -0,0 +1,170 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {AppCallResponseTypes} from 'mattermost-redux/constants/apps'; + +import EmojiMap from 'utils/emoji_map'; + +import {shallowWithIntl} from 'tests/helpers/intl-test-helper'; + +import AppsFormContainer from './apps_form_container'; + +describe('components/apps_form/AppsFormContainer', () => { + const emojiMap = new EmojiMap(new Map()); + + const context = { + app_id: 'app', + channel_id: 'channel', + team_id: 'team', + post_id: 'post', + }; + + const baseProps = { + emojiMap, + form: { + title: 'Form Title', + header: 'Form Header', + fields: [ + { + type: 'text', + name: 'field1', + value: 'initial_value_1', + }, + { + type: 'static_select', + name: 'field2', + value: 'initial_value_2', + refresh: true, + }, + ], + }, + call: { + context, + path: '/form_url', + }, + actions: { + doAppCall: jest.fn().mockResolvedValue({}), + }, + onHide: jest.fn(), + }; + + test('should match snapshot', () => { + const props = baseProps; + + const wrapper = shallowWithIntl(); + expect(wrapper).toMatchSnapshot(); + }); + + describe('submitForm', () => { + test('should handle form submission result', async () => { + const response = { + data: { + type: AppCallResponseTypes.OK, + }, + }; + + const props = { + ...baseProps, + actions: { + ...baseProps.actions, + doAppCall: jest.fn().mockResolvedValue(response), + }, + }; + + const wrapper = shallowWithIntl(); + const result = await wrapper.instance().submitForm({ + values: { + field1: 'value1', + field2: {label: 'label2', value: 'value2'}, + }, + }); + + expect(props.actions.doAppCall).toHaveBeenCalledWith({ + context: { + app_id: 'app', + channel_id: 'channel', + post_id: 'post', + team_id: 'team', + }, + path: '/form_url', + expand: {}, + values: { + field1: 'value1', + field2: { + label: 'label2', + value: 'value2', + }, + }, + }, 'submit', expect.any(Object)); + + expect(result).toEqual({ + data: { + type: AppCallResponseTypes.OK, + }, + }); + }); + }); + + describe('performLookupCall', () => { + test('should handle form user input', async () => { + const response = { + data: { + type: AppCallResponseTypes.OK, + data: { + items: [{ + label: 'Fetched Label', + value: 'fetched_value', + }], + }, + }, + }; + + const props = { + ...baseProps, + actions: { + ...baseProps.actions, + doAppCall: jest.fn().mockResolvedValue(response), + }, + }; + + const form = props.form; + + const wrapper = shallowWithIntl(); + const result = await wrapper.instance().performLookupCall( + form.fields[1], + { + field1: 'value1', + field2: {label: 'label2', value: 'value2'}, + }, + 'My search', + ); + + expect(props.actions.doAppCall).toHaveBeenCalledWith({ + context: { + app_id: 'app', + channel_id: 'channel', + post_id: 'post', + team_id: 'team', + }, + path: '/form_url', + expand: {}, + query: 'My search', + selected_field: 'field2', + values: { + field1: 'value1', + field2: { + label: 'label2', + value: 'value2', + }, + }, + }, 'lookup', expect.any(Object)); + + expect(result).toEqual([{ + label: 'Fetched Label', + value: 'fetched_value', + }]); + }); + }); +}); diff --git a/components/apps_form/apps_form_container.tsx b/components/apps_form/apps_form_container.tsx new file mode 100644 index 000000000000..43443e9033c0 --- /dev/null +++ b/components/apps_form/apps_form_container.tsx @@ -0,0 +1,212 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {injectIntl, IntlShape} from 'react-intl'; + +import {AppCallResponse, AppField, AppForm, AppFormValues, AppSelectOption, AppCallType, AppCallRequest} from 'mattermost-redux/types/apps'; +import {AppCallTypes, AppCallResponseTypes} from 'mattermost-redux/constants/apps'; + +import {makeCallErrorResponse} from 'utils/apps'; + +import {sendEphemeralPost} from 'actions/global_actions'; + +import AppsForm from './apps_form'; + +type Props = { + intl: IntlShape; + form?: AppForm; + call?: AppCallRequest; + onHide: () => void; + actions: { + doAppCall: (call: AppCallRequest, type: AppCallType, intl: IntlShape) => Promise<{data: AppCallResponse}>; + }; +}; + +type State = { + form?: AppForm; +} + +class AppsFormContainer extends React.PureComponent { + constructor(props: Props) { + super(props); + this.state = {form: props.form}; + } + + submitForm = async (submission: {values: AppFormValues}): Promise<{data: AppCallResponse}> => { + //TODO use FormResponseData instead of Any + const makeErrorMsg = (msg: string) => { + return this.props.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) { + const errMsg = this.props.intl.formatMessage({id: 'apps.error.form.no_form', defaultMessage: '`form` is not defined'}); + return {data: makeCallErrorResponse(makeErrorMsg(errMsg))}; + } + + const call = this.getCall(); + if (!call) { + const errMsg = this.props.intl.formatMessage({id: 'apps.error.form.no_call', defaultMessage: '`call` is not defined'}); + return {data: makeCallErrorResponse(makeErrorMsg(errMsg))}; + } + + const res = await this.props.actions.doAppCall({ + ...call, + values: submission.values, + }, AppCallTypes.SUBMIT, this.props.intl); + + const callResp = res.data; + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.markdown) { + sendEphemeralPost(callResp.markdown); + } + break; + case AppCallResponseTypes.FORM: + this.setState({form: callResp.form}); + break; + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.ERROR: + break; + default: + return {data: makeCallErrorResponse(makeErrorMsg(this.props.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}> => { + const makeErrMsg = (message: string) => this.props.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 {data: makeCallErrorResponse(makeErrMsg(this.props.intl.formatMessage({id: 'apps.error.form.no_form', defaultMessage: '`form` is not defined.'})))}; + } + + const call = this.getCall(); + if (!call) { + return {data: makeCallErrorResponse(makeErrMsg(this.props.intl.formatMessage({id: 'apps.error.form.no_call', defaultMessage: '`call` is not defined.'})))}; + } + + if (!field.refresh) { + // Should never happen + return {data: makeCallErrorResponse(makeErrMsg(this.props.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, this.props.intl); + + const callResp = res.data; + switch (callResp.type) { + case AppCallResponseTypes.FORM: + this.setState({form: callResp.form}); + break; + case AppCallResponseTypes.OK: + case AppCallResponseTypes.NAVIGATE: + return {data: makeCallErrorResponse(makeErrMsg(this.props.intl.formatMessage( + {id: 'apps.error.responses.unexpected_type', defaultMessage: 'App response type was not expected. Response type: {type}.'}, + {type: callResp.type}, + )))}; + case AppCallResponseTypes.ERROR: + break; + default: + return {data: makeCallErrorResponse(makeErrMsg(this.props.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, formValues: AppFormValues, userInput: string): Promise => { + const call = this.getCall(); + if (!call) { + return []; + } + + const res = await this.props.actions.doAppCall({ + ...call, + values: formValues, + query: userInput, + selected_field: field.name, + }, AppCallTypes.LOOKUP, this.props.intl); + + // TODO Surface errors? + if (res.data.type !== AppCallResponseTypes.OK) { + return []; + } + + const data = res.data.data as {items: AppSelectOption[]}; + if (data.items && data.items.length) { + return data.items; + } + + return []; + } + + getCall = (): AppCallRequest | null => { + const {form} = this.state; + + const {call} = this.props; + if (!call) { + return null; + } + + return { + ...call, + ...form?.call, + expand: { + ...form?.call?.expand, + ...call.expand, + }, + }; + } + + onHide = () => { + this.props.onHide(); + }; + + render() { + const call = this.getCall(); + if (!call) { + return null; + } + + const {form} = this.state; + if (!form) { + return null; + } + + return ( + + ); + } +} + +export default injectIntl(AppsFormContainer); diff --git a/components/apps_form/apps_form_field/apps_form_field.test.tsx b/components/apps_form/apps_form_field/apps_form_field.test.tsx new file mode 100644 index 000000000000..d32c462de9fb --- /dev/null +++ b/components/apps_form/apps_form_field/apps_form_field.test.tsx @@ -0,0 +1,319 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +// +import React from 'react'; +import {shallow} from 'enzyme'; + +import {AppField} from 'mattermost-redux/types/apps'; + +import TextSetting from 'components/widgets/settings/text_setting'; + +import AutocompleteSelector from 'components/autocomplete_selector'; +import GenericUserProvider from 'components/suggestion/generic_user_provider.jsx'; +import GenericChannelProvider from 'components/suggestion/generic_channel_provider.jsx'; + +import AppsFormField, {Props} from './apps_form_field'; +import AppsFormSelectField from './apps_form_select_field'; + +describe('components/apps_form/apps_form_field/AppsFormField', () => { + describe('Text elements', () => { + const textField: AppField = { + name: 'field1', + type: 'text', + max_length: 100, + modal_label: 'The Field', + hint: 'The hint', + description: 'The description', + is_required: true, + }; + + const baseDialogTextProps: Props = { + name: 'testing', + actions: { + autocompleteChannels: jest.fn(), + autocompleteUsers: jest.fn(), + }, + field: textField, + value: '', + onChange: () => {}, + performLookup: jest.fn(), + }; + + const baseTextSettingProps = { + inputClassName: '', + label: ( + + {baseDialogTextProps.field.modal_label} + {' *'} + + ), + maxLength: 100, + placeholder: 'The hint', + resizable: false, + type: 'input', + value: '', + id: baseDialogTextProps.name, + helpText: 'The description', + }; + it('subtype blank', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.matchesElement( + , + )).toEqual(true); + }); + + it('subtype email', () => { + const wrapper = shallow( + , + ); + expect(wrapper.matchesElement( + , + )).toEqual(true); + }); + + it('subtype invalid', () => { + const wrapper = shallow( + , + ); + expect(wrapper.matchesElement( + , + )).toEqual(true); + }); + + it('subtype password', () => { + const wrapper = shallow( + , + ); + expect(wrapper.matchesElement( + , + )).toEqual(true); + }); + }); + + describe('Select elements', () => { + const selectField: AppField = { + name: 'field1', + type: 'static_select', + max_length: 100, + modal_label: 'The Field', + hint: 'The hint', + description: 'The description', + is_required: true, + options: [], + }; + + const baseDialogSelectProps: Props = { + name: 'testing', + actions: { + autocompleteChannels: jest.fn(), + autocompleteUsers: jest.fn(), + }, + field: selectField, + value: null, + onChange: () => {}, + performLookup: jest.fn(), + }; + + const options = [ + {value: 'foo', label: 'foo-text'}, + {value: 'bar', label: 'bar-text'}, + ]; + + test('AppsFormSelectField is rendered when type is static_select', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.find(AppsFormSelectField).exists()).toBe(true); + }); + + test('AppsFormSelectField is rendered when type is dynamic_select', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.find(AppsFormSelectField).exists()).toBe(true); + }); + + test('GenericUserProvider is used when field type is user', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.find(AutocompleteSelector).exists()).toBe(true); + expect(wrapper.find(AutocompleteSelector).prop('providers')[0]).toBeInstanceOf(GenericUserProvider); + }); + + test('GenericChannelProvider is used when field type is channel', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.find(AutocompleteSelector).exists()).toBe(true); + expect(wrapper.find(AutocompleteSelector).prop('providers')[0]).toBeInstanceOf(GenericChannelProvider); + }); + + test('AppSelectForm is rendered when options are undefined', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.find(AppsFormSelectField).exists()).toBe(true); + }); + + test('AppsFormSelectField is rendered when options are null and value is null', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.find(AppsFormSelectField).exists()).toBe(true); + }); + + test('AppsFormSelectField is rendered when options are null and value is not null', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.find(AppsFormSelectField).exists()).toBe(true); + }); + + test('AppsFormSelectField is rendered when value is not one of the options', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.find(AppsFormSelectField).exists()).toBe(true); + }); + + test('No default value is selected from the options list', () => { + const wrapper = shallow( + , + ); + expect(wrapper.find(AppsFormSelectField).prop('value')).toBeNull(); + }); + + test('The default value can be specified from the list', () => { + const wrapper = shallow( + , + ); + expect(wrapper.find(AppsFormSelectField).prop('value')).toBe(options[1]); + }); + }); +}); diff --git a/components/apps_form/apps_form_field/apps_form_field.tsx b/components/apps_form/apps_form_field/apps_form_field.tsx new file mode 100644 index 000000000000..431d6d412599 --- /dev/null +++ b/components/apps_form/apps_form_field/apps_form_field.tsx @@ -0,0 +1,206 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {AppField, AppSelectOption} from 'mattermost-redux/types/apps'; +import {Channel} from 'mattermost-redux/types/channels'; +import {UserProfile} from 'mattermost-redux/types/users'; + +import {AppFieldTypes} from 'mattermost-redux/constants/apps'; +import {displayUsername} from 'mattermost-redux/utils/user_utils'; + +import GenericUserProvider from 'components/suggestion/generic_user_provider.jsx'; +import GenericChannelProvider from 'components/suggestion/generic_channel_provider.jsx'; + +import TextSetting, {InputTypes} from 'components/widgets/settings/text_setting'; +import AutocompleteSelector from 'components/autocomplete_selector'; +import ModalSuggestionList from 'components/suggestion/modal_suggestion_list.jsx'; +import BoolSetting from 'components/widgets/settings/bool_setting'; +import Provider from 'components/suggestion/provider'; + +import AppsFormSelectField from './apps_form_select_field'; + +const TEXT_DEFAULT_MAX_LENGTH = 150; +const TEXTAREA_DEFAULT_MAX_LENGTH = 3000; + +export type Props = { + field: AppField; + name: string; + errorText?: React.ReactNode; + teammateNameDisplay?: string; + + value: AppSelectOption | string | boolean | number | null; + onChange: (name: string, value: any) => void; + autoFocus?: boolean; + listComponent?: React.ComponentClass; + performLookup: (name: string, userInput: string) => Promise; + actions: { + autocompleteChannels: (term: string, success: (channels: Channel[]) => void, error: () => void) => (dispatch: any, getState: any) => Promise; + autocompleteUsers: (search: string) => Promise; + }; +} + +export default class AppsFormField extends React.PureComponent { + private providers: Provider[] = []; + + static defaultProps = { + listComponent: ModalSuggestionList, + }; + + constructor(props: Props) { + super(props); + this.setProviders(); + } + + handleSelected = (selected: AppSelectOption | UserProfile | Channel) => { + const {name, field, onChange} = this.props; + + if (field.type === AppFieldTypes.USER) { + const user = selected as UserProfile; + let selectedLabel = user.username; + if (this.props.teammateNameDisplay) { + selectedLabel = displayUsername(user, this.props.teammateNameDisplay); + } + const option = {label: selectedLabel, value: user.id}; + onChange(name, option); + } else if (field.type === AppFieldTypes.CHANNEL) { + const channel = selected as Channel; + const option = {label: channel.display_name, value: channel.id}; + onChange(name, option); + } else { + const option = selected as AppSelectOption; + onChange(name, option); + } + } + + setProviders = () => { + const {actions, field} = this.props; + + let providers: Provider[] = []; + if (field.type === AppFieldTypes.USER) { + providers = [new GenericUserProvider(actions.autocompleteUsers)]; + } else if (field.type === AppFieldTypes.CHANNEL) { + providers = [new GenericChannelProvider(actions.autocompleteChannels)]; + } + + this.providers = providers; + } + + render() { + const { + field, + name, + value, + onChange, + errorText, + listComponent, + } = this.props; + + const placeholder = field.hint || ''; + + const displayName = (field.modal_label || field.label) as string; + let displayNameContent: React.ReactNode = (field.modal_label || field.label) as string; + if (field.is_required) { + displayNameContent = ( + + {displayName} + {' *'} + + ); + } + + const helpText = field.description; + let helpTextContent: React.ReactNode = helpText; + if (errorText) { + helpTextContent = ( + + {helpText} +
+ {errorText} +
+
+ ); + } + + if (field.type === 'text') { + 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: InputTypes = 'input'; + if (subtype && TextSetting.validTypes.includes(subtype)) { + textType = subtype as InputTypes; + } + + const textValue = value as string; + return ( + + ); + } else if (field.type === AppFieldTypes.CHANNEL || field.type === AppFieldTypes.USER) { + let selectedValue: string | undefined; + if (this.props.value) { + selectedValue = (this.props.value as AppSelectOption).label; + } + return ( + + ); + } else if (field.type === AppFieldTypes.STATIC_SELECT || field.type === AppFieldTypes.DYNAMIC_SELECT) { + return ( + + ); + } else if (field.type === AppFieldTypes.BOOL) { + const boolValue = value as boolean; + return ( + + ); + } + + return null; + } +} diff --git a/components/apps_form/apps_form_field/apps_form_select_field.tsx b/components/apps_form/apps_form_field/apps_form_select_field.tsx new file mode 100644 index 000000000000..2fd9de16e919 --- /dev/null +++ b/components/apps_form/apps_form_field/apps_form_select_field.tsx @@ -0,0 +1,128 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import ReactSelect from 'react-select'; +import {Props as AsyncSelectProps} from 'react-select/async'; + +import {AppField, AppSelectOption} from 'mattermost-redux/types/apps'; + +const AsyncSelect = require('react-select/lib/Async').default as React.ElementType>; // eslint-disable-line global-require + +export type Props = { + field: AppField; + label: React.ReactNode; + helpText: React.ReactNode; + value: AppSelectOption | null; + onChange: (value: AppSelectOption) => void; + performLookup: (name: string, userInput: string) => Promise; +}; + +export type State = { + refreshNonce: string; + field: AppField; +} + +export default class AppsFormSelectField extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + field: props.field, + refreshNonce: Math.random().toString(), + }; + } + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + if (nextProps.field !== prevState.field) { + return { + field: nextProps.field, + refreshNonce: Math.random().toString(), + }; + } + + return null; + } + + onChange = (selectedOption: AppSelectOption) => { + this.props.onChange(selectedOption); + } + + loadDynamicOptions = async (userInput: string): Promise => { + return this.props.performLookup(this.props.field.name, userInput); + } + + renderDynamicSelect() { + const {field} = this.props; + const placeholder = field.hint || ''; + const value = this.props.value; + + return ( +
+ +
+ ); + } + + renderStaticSelect() { + const {field} = this.props; + + const placeholder = field.hint || ''; + + const options = field.options; + const value = this.props.value; + + return ( +
+ +
+ ); + } + + render() { + const {field, label, helpText} = this.props; + + let selectComponent; + if (field.type === 'dynamic_select') { + selectComponent = this.renderDynamicSelect(); + } else if (field.type === 'static_select') { + selectComponent = this.renderStaticSelect(); + } else { + return null; + } + + return ( +
+ + + {selectComponent} +
+ {helpText} +
+
+
+ ); + } +} diff --git a/components/apps_form/apps_form_field/index.ts b/components/apps_form/apps_form_field/index.ts new file mode 100644 index 000000000000..6d9fa11ca540 --- /dev/null +++ b/components/apps_form/apps_form_field/index.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; + +import {ActionFunc, GenericAction} from 'mattermost-redux/types/actions'; +import {Channel} from 'mattermost-redux/types/channels'; +import {UserProfile} from 'mattermost-redux/types/users'; + +import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences'; + +import {GlobalState} from 'mattermost-redux/types/store'; + +import {autocompleteChannels} from 'actions/channel_actions'; +import {autocompleteUsers} from 'actions/user_actions'; + +import AppsFormField from './apps_form_field'; + +function mapStateToProps(state: GlobalState) { + return { + teammateNameDisplay: getTeammateNameDisplaySetting(state), + }; +} +type Actions = { + autocompleteChannels: (term: string, success: (channels: Channel[]) => void, error: () => void) => (dispatch: any, getState: any) => Promise; + autocompleteUsers: (search: string) => Promise; +}; + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators, Actions>({ + autocompleteChannels, + autocompleteUsers, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(AppsFormField); diff --git a/components/apps_form/apps_form_header.test.tsx b/components/apps_form/apps_form_header.test.tsx new file mode 100644 index 000000000000..34af4f2afdbb --- /dev/null +++ b/components/apps_form/apps_form_header.test.tsx @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import AppsFormHeader from './apps_form_header'; + +describe('components/apps_form/AppsFormHeader', () => { + test('should render message with supported values', () => { + const props = { + id: 'testsupported', + value: '**bold** *italic* [link](https://mattermost.com/)
[link target blank](!https://mattermost.com/)', + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should not fail on empty value', () => { + const props = { + id: 'testblankvalue', + value: '', + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/components/apps_form/apps_form_header.tsx b/components/apps_form/apps_form_header.tsx new file mode 100644 index 000000000000..21e0b59a3266 --- /dev/null +++ b/components/apps_form/apps_form_header.tsx @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import Markdown from 'components/markdown'; + +type Props = { + id?: string; + value: string; +}; + +const markdownOptions = {singleline: false, mentionHighlight: false}; + +const AppsFormHeader: React.FC = (props: Props) => { + return ( + + ); +}; + +export default AppsFormHeader; diff --git a/components/apps_form/index.ts b/components/apps_form/index.ts new file mode 100644 index 000000000000..42aa67ff1c0e --- /dev/null +++ b/components/apps_form/index.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; + +import {ActionFunc, GenericAction} from 'mattermost-redux/types/actions'; +import {AppCallRequest, AppCallResponse, AppCallType} from 'mattermost-redux/types/apps'; + +import {doAppCall} from 'actions/apps'; + +import AppsFormContainer from './apps_form_container'; + +type Actions = { + doAppCall: (call: AppCallRequest, type: AppCallType) => Promise<{data: AppCallResponse}>; +}; + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators, Actions>({ + doAppCall, + }, dispatch), + }; +} + +export default connect(null, mapDispatchToProps)(AppsFormContainer); diff --git a/components/button_selector.tsx b/components/button_selector.tsx new file mode 100644 index 000000000000..8b0fab95c705 --- /dev/null +++ b/components/button_selector.tsx @@ -0,0 +1,106 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {AppSelectOption} from 'mattermost-redux/types/apps'; + +type Props = { + id: string; + value: string | null; + onChange?: (value: AppSelectOption) => void; + label?: React.ReactNode; + labelClassName?: string; + inputClassName?: string; + helpText?: React.ReactNode; + footer?: React.ReactNode; + disabled?: boolean; + shouldSubmit?: boolean; + options?: AppSelectOption[] | null; +} + +const defaultProps: Partial = { + value: '', + labelClassName: '', + inputClassName: '', +}; + +const ButtonSelector: React.FC = (props: Props) => { + const onClick = React.useCallback((value: AppSelectOption) => { + if (props.onChange) { + props.onChange(value); + } + }, [props.onChange]); + + const { + footer, + label, + labelClassName, + helpText, + inputClassName, + disabled, + options, + shouldSubmit, + value, + } = props; + + let labelContent; + if (label) { + labelContent = ( + + ); + } + + let helpTextContent; + if (helpText) { + helpTextContent = ( +
+ {helpText} +
+ ); + } + + const buttons = options?.map((opt) => { + let className = 'btn btn-link'; + if (disabled) { + className += ' btn-inactive'; + } + if (opt.value === value) { + className += ' btn-primary'; + } + const type = shouldSubmit ? 'submit' : 'button'; + return ( + + ); + }); + + return ( +
+ {labelContent} +
+ {buttons} + {helpTextContent} + {footer} +
+
+ ); +}; + +ButtonSelector.defaultProps = defaultProps; + +export default ButtonSelector; diff --git a/components/channel_header/__snapshots__/channel_header.test.jsx.snap b/components/channel_header/__snapshots__/channel_header.test.jsx.snap index 04ad6d27f73d..f05beabb1144 100644 --- a/components/channel_header/__snapshots__/channel_header.test.jsx.snap +++ b/components/channel_header/__snapshots__/channel_header.test.jsx.snap @@ -147,7 +147,7 @@ exports[`components/ChannelHeader should render active flagged posts 1`] = `
- - - - - - - - - + + +`; diff --git a/components/dot_menu/__snapshots__/dot_menu_mobile.test.tsx.snap b/components/dot_menu/__snapshots__/dot_menu_mobile.test.tsx.snap index 073362e375e7..9e861936eb8e 100644 --- a/components/dot_menu/__snapshots__/dot_menu_mobile.test.tsx.snap +++ b/components/dot_menu/__snapshots__/dot_menu_mobile.test.tsx.snap @@ -1,3 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`components/dot_menu/DotMenu on mobile view should match snapshot 1`] = `""`; +exports[`components/dot_menu/DotMenu on mobile view should match snapshot 1`] = ` + + + +`; diff --git a/components/dot_menu/dot_menu.test.tsx b/components/dot_menu/dot_menu.test.jsx similarity index 85% rename from components/dot_menu/dot_menu.test.tsx rename to components/dot_menu/dot_menu.test.jsx index b38b89adef86..711806b9470f 100644 --- a/components/dot_menu/dot_menu.test.tsx +++ b/components/dot_menu/dot_menu.test.jsx @@ -1,11 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {shallow, ShallowWrapper} from 'enzyme'; - import React from 'react'; -import {PostType} from 'mattermost-redux/types/posts'; +import {shallowWithIntl} from 'tests/helpers/intl-test-helper'; import {Locations, PostTypes} from 'utils/constants'; import {TestHelper} from 'utils/test_helper'; @@ -22,7 +20,7 @@ jest.mock('utils/utils', () => { describe('components/dot_menu/DotMenu', () => { const baseProps = { - post: TestHelper.getPostMock({id: 'post_id_1', is_pinned: false, type: '' as PostType}), + post: TestHelper.getPostMock({id: 'post_id_1', is_pinned: false, type: ''}), isLicensed: false, postEditTimeLimit: '-1', handleCommentClick: jest.fn(), @@ -39,9 +37,14 @@ describe('components/dot_menu/DotMenu', () => { unpinPost: jest.fn(), openModal: jest.fn(), markPostAsUnread: jest.fn(), + doAppCall: jest.fn(), }, canEdit: false, canDelete: false, + appBindings: [], + pluginMenuItems: [], + appsEnabled: false, + isReadOnly: false, }; test('should match snapshot, on Center', () => { @@ -49,7 +52,7 @@ describe('components/dot_menu/DotMenu', () => { ...baseProps, canEdit: true, }; - const wrapper: ShallowWrapper = shallow( + const wrapper = shallowWithIntl( , ); @@ -68,7 +71,7 @@ describe('components/dot_menu/DotMenu', () => { canEdit: true, canDelete: true, }; - const wrapper: ShallowWrapper = shallow( + const wrapper = shallowWithIntl( , ); @@ -81,7 +84,7 @@ describe('components/dot_menu/DotMenu', () => { canEdit: true, canDelete: true, }; - const wrapper: ShallowWrapper = shallow( + const wrapper = shallowWithIntl( , ); @@ -101,10 +104,10 @@ describe('components/dot_menu/DotMenu', () => { ...baseProps, post: TestHelper.getPostMock({ ...baseProps.post, - type: PostTypes.JOIN_CHANNEL as PostType, + type: PostTypes.JOIN_CHANNEL, }), }; - const wrapper: ShallowWrapper = shallow( + const wrapper = shallowWithIntl( , ); @@ -112,7 +115,7 @@ describe('components/dot_menu/DotMenu', () => { }); test('should have divider when plugin menu item exists', () => { - const wrapper: ShallowWrapper = shallow( + const wrapper = shallowWithIntl( , ); expect(wrapper.find('#divider_post_post_id_1_plugins').exists()).toBe(false); @@ -126,7 +129,7 @@ describe('components/dot_menu/DotMenu', () => { }); test('should have divider when pluggable menu item exists', () => { - const wrapper: ShallowWrapper = shallow( + const wrapper = shallowWithIntl( , ); expect(wrapper.find('#divider_post_post_id_1_plugins').exists()).toBe(false); @@ -140,7 +143,7 @@ describe('components/dot_menu/DotMenu', () => { }); test('should show mark as unread when channel is not archived', () => { - const wrapper: ShallowWrapper = shallow( + const wrapper = shallowWithIntl( , ); @@ -152,7 +155,7 @@ describe('components/dot_menu/DotMenu', () => { ...baseProps, channelIsArchived: true, }; - const wrapper: ShallowWrapper = shallow( + const wrapper = shallowWithIntl( , ); @@ -164,7 +167,7 @@ describe('components/dot_menu/DotMenu', () => { ...baseProps, location: Locations.SEARCH, }; - const wrapper: ShallowWrapper = shallow( + const wrapper = shallowWithIntl( , ); diff --git a/components/dot_menu/dot_menu.tsx b/components/dot_menu/dot_menu.tsx index 7bd7645da3c0..f766e24eefd0 100644 --- a/components/dot_menu/dot_menu.tsx +++ b/components/dot_menu/dot_menu.tsx @@ -3,11 +3,15 @@ import React from 'react'; import classNames from 'classnames'; -import {FormattedMessage} from 'react-intl'; +import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'; import {Tooltip} from 'react-bootstrap'; import Permissions from 'mattermost-redux/constants/permissions'; import {Post} from 'mattermost-redux/types/posts'; +import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from 'mattermost-redux/types/apps'; +import {AppCallResponseTypes, AppCallTypes, AppExpandLevels} from 'mattermost-redux/constants/apps'; + +import {ActionResult} from 'mattermost-redux/types/actions'; import {Locations, ModalIdentifiers, Constants} from 'utils/constants'; import DeletePostModal from 'components/delete_post_modal'; @@ -20,11 +24,15 @@ import Pluggable from 'plugins/pluggable'; import Menu from 'components/widgets/menu/menu'; import MenuWrapper from 'components/widgets/menu/menu_wrapper'; import DotsHorizontalIcon from 'components/widgets/icons/dots_horizontal'; +import {PluginComponent} from 'types/store/plugins'; +import {sendEphemeralPost} from 'actions/global_actions'; +import {createCallContext, createCallRequest} from 'utils/apps'; const MENU_BOTTOM_MARGIN = 80; export const PLUGGABLE_COMPONENT = 'PostDropdownMenuItem'; type Props = { + intl: IntlShape; post: Post; teamId?: string; location?: 'CENTER' | 'RHS_ROOT' | 'RHS_COMMENT' | 'SEARCH' | string; @@ -35,19 +43,23 @@ type Props = { handleAddReactionClick?: () => void; isMenuOpen?: boolean; isReadOnly: boolean | null; - pluginMenuItems?: any[]; + pluginMenuItems?: PluginComponent[]; isLicensed?: boolean; // TechDebt: Made non-mandatory while converting to typescript postEditTimeLimit?: string; // TechDebt: Made non-mandatory while converting to typescript enableEmojiPicker?: boolean; // TechDebt: Made non-mandatory while converting to typescript channelIsArchived?: boolean; // TechDebt: Made non-mandatory while converting to typescript currentTeamUrl?: string; // TechDebt: Made non-mandatory while converting to typescript + appBindings?: AppBinding[]; + appsEnabled: boolean; /** * Components for overriding provided by plugins */ - components?: any; // TechDebt: Made non-mandatory while converting to typescript + components: { + [componentName: string]: PluginComponent[]; + }; - actions?: { + actions: { /** * Function flag the post @@ -83,6 +95,11 @@ type Props = { * Function to set the unread mark at given post */ markPostAsUnread: (post: Post) => void; + + /** + * Function to perform an app call + */ + doAppCall: (call: AppCallRequest, type: AppCallType, intl: IntlShape) => Promise; }; // TechDebt: Made non-mandatory while converting to typescript canEdit: boolean; @@ -95,13 +112,14 @@ type State = { canDelete: boolean; } -export default class DotMenu extends React.PureComponent { - static defaultProps = { +class DotMenu extends React.PureComponent { + public static defaultProps: Partial = { commentCount: 0, isFlagged: false, isReadOnly: false, location: Locations.CENTER, pluginMenuItems: [], + appBindings: [], } private editDisableAction: DelayedAction; private buttonRef: React.RefObject; @@ -121,9 +139,11 @@ export default class DotMenu extends React.PureComponent { } disableCanEditPostByTime() { - const {post, isLicensed, postEditTimeLimit} = this.props; + const {post, isLicensed} = this.props; const {canEdit} = this.state; + const postEditTimeLimit = this.props.postEditTimeLimit || Constants.UNSET_POST_EDIT_TIME_LIMIT; + if (canEdit && isLicensed) { if (postEditTimeLimit !== String(Constants.UNSET_POST_EDIT_TIME_LIMIT)) { const milliseconds = 1000; @@ -156,9 +176,9 @@ export default class DotMenu extends React.PureComponent { handleFlagMenuItemActivated = () => { if (this.props.isFlagged) { - this.props.actions?.unflagPost(this.props.post.id); + this.props.actions.unflagPost(this.props.post.id); } else { - this.props.actions?.flagPost(this.props.post.id); + this.props.actions.flagPost(this.props.post.id); } } @@ -178,15 +198,15 @@ export default class DotMenu extends React.PureComponent { handlePinMenuItemActivated = () => { if (this.props.post.is_pinned) { - this.props.actions?.unpinPost(this.props.post.id); + this.props.actions.unpinPost(this.props.post.id); } else { - this.props.actions?.pinPost(this.props.post.id); + this.props.actions.pinPost(this.props.post.id); } } handleUnreadMenuItemActivated = (e: React.MouseEvent) => { e.preventDefault(); - this.props.actions?.markPostAsUnread(this.props.post); + this.props.actions.markPostAsUnread(this.props.post); } handleDeleteMenuItemActivated = (e: React.MouseEvent) => { @@ -202,11 +222,11 @@ export default class DotMenu extends React.PureComponent { }, }; - this.props.actions?.openModal(deletePostModalData); + this.props.actions.openModal(deletePostModalData); } handleEditMenuItemActivated = () => { - this.props.actions?.setEditingPost( + this.props.actions.setEditingPost( this.props.post.id, this.props.commentCount, this.props.location === Locations.CENTER ? 'post_textbox' : 'reply_textbox', @@ -258,6 +278,53 @@ export default class DotMenu extends React.PureComponent { ); } + onClickAppBinding = async (binding: AppBinding) => { + if (!binding.call) { + return; + } + const context = createCallContext( + binding.app_id, + binding.location, + this.props.post.channel_id, + this.props.teamId, + this.props.post.id, + this.props.post.root_id, + ); + const call = createCallRequest( + binding.call, + context, + { + post: AppExpandLevels.ALL, + }, + ); + const res = await this.props.actions?.doAppCall(call, AppCallTypes.SUBMIT, this.props.intl); + + const callResp = (res as {data: AppCallResponse}).data; + const ephemeral = (message: string) => sendEphemeralPost(message, this.props.post.channel_id, this.props.post.root_id); + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.markdown) { + ephemeral(callResp.markdown); + } + break; + case AppCallResponseTypes.ERROR: { + const errorMessage = callResp.error || this.props.intl.formatMessage({id: 'apps.error.unknown', defaultMessage: 'Unknown error happenned'}); + ephemeral(errorMessage); + break; + } + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.FORM: + break; + default: { + const errMessage = this.props.intl.formatMessage( + {id: 'apps.error.responses.unknown_type', defaultMessage: 'App response type not supported. Response type: {type}.'}, + {type: callResp.type}, + ); + ephemeral(errMessage); + } + } + } + render() { const isSystemMessage = PostUtils.isSystemMessage(this.props.post); const isMobile = Utils.isMobile(); @@ -291,7 +358,26 @@ export default class DotMenu extends React.PureComponent { }} /> ); + }) || []; + + let appBindings = [] as JSX.Element[]; + if (this.props.appsEnabled && this.props.appBindings) { + appBindings = this.props.appBindings.map((item) => { + let icon: JSX.Element | undefined; + if (item.icon) { + icon = (); + } + + return ( + this.onClickAppBinding(item)} + icon={icon} + /> + ); }); + } if (!this.state.canDelete && !this.state.canEdit && typeof pluginItems !== 'undefined' && pluginItems.length === 0 && isSystemMessage) { return null; @@ -390,8 +476,9 @@ export default class DotMenu extends React.PureComponent { onClick={this.handleDeleteMenuItemActivated} isDangerous={true} /> - {((typeof pluginItems !== 'undefined' && pluginItems.length > 0) || (this.props.components[PLUGGABLE_COMPONENT] && this.props.components[PLUGGABLE_COMPONENT].length > 0)) && this.renderDivider('plugins')} + {((typeof pluginItems !== 'undefined' && pluginItems.length > 0) || appBindings.length > 0 || (this.props.components[PLUGGABLE_COMPONENT] && this.props.components[PLUGGABLE_COMPONENT].length > 0)) && this.renderDivider('plugins')} {pluginItems} + {appBindings} { ); } } + +export default injectIntl(DotMenu); diff --git a/components/dot_menu/dot_menu_empty.test.tsx b/components/dot_menu/dot_menu_empty.test.tsx index ef2a047d3ef6..2b12976875f8 100644 --- a/components/dot_menu/dot_menu_empty.test.tsx +++ b/components/dot_menu/dot_menu_empty.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {shallow, ShallowWrapper} from 'enzyme'; +import {shallow} from 'enzyme'; import React from 'react'; import DotMenu from 'components/dot_menu/dot_menu'; @@ -40,12 +40,17 @@ describe('components/dot_menu/DotMenu returning empty ("")', () => { unpinPost: jest.fn(), openModal: jest.fn(), markPostAsUnread: jest.fn(), + doAppCall: jest.fn(), }, canEdit: false, canDelete: false, + appBindings: [], + pluginMenuItems: [], + appsEnabled: false, + isReadOnly: false, }; - const wrapper: ShallowWrapper = shallow( + const wrapper = shallow( , ); diff --git a/components/dot_menu/dot_menu_mobile.test.tsx b/components/dot_menu/dot_menu_mobile.test.tsx index 8aab0262ff86..75bdd4a41c43 100644 --- a/components/dot_menu/dot_menu_mobile.test.tsx +++ b/components/dot_menu/dot_menu_mobile.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {shallow, ShallowWrapper} from 'enzyme'; +import {shallow} from 'enzyme'; import React from 'react'; import DotMenu from 'components/dot_menu/dot_menu'; @@ -40,12 +40,17 @@ describe('components/dot_menu/DotMenu on mobile view', () => { unpinPost: jest.fn(), openModal: jest.fn(), markPostAsUnread: jest.fn(), + doAppCall: jest.fn(), }, canEdit: false, canDelete: false, + appBindings: [], + pluginMenuItems: [], + appsEnabled: false, + isReadOnly: false, }; - const wrapper: ShallowWrapper = shallow( + const wrapper = shallow( , ); diff --git a/components/dot_menu/index.ts b/components/dot_menu/index.ts index 7925be8af6e5..1813fa2ab8bc 100644 --- a/components/dot_menu/index.ts +++ b/components/dot_menu/index.ts @@ -2,20 +2,25 @@ // See LICENSE.txt for license information. import {connect} from 'react-redux'; -import {bindActionCreators, Dispatch} from 'redux'; +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; import {getLicense, getConfig} from 'mattermost-redux/selectors/entities/general'; import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getCurrentTeamId, getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; +import {appsEnabled, makeAppBindingsSelector} from 'mattermost-redux/selectors/entities/apps'; +import {AppBindingLocations} from 'mattermost-redux/constants/apps'; -import {GenericAction} from 'mattermost-redux/types/actions'; +import {ActionFunc, ActionResult, GenericAction} from 'mattermost-redux/types/actions'; import {Post} from 'mattermost-redux/types/posts'; +import {AppCallRequest, AppCallType} from 'mattermost-redux/types/apps'; + import {GlobalState} from 'types/store'; import {openModal} from 'actions/views/modals'; +import {doAppCall} from 'actions/apps'; import { flagPost, unflagPost, @@ -44,6 +49,8 @@ type Props = { enableEmojiPicker?: boolean; }; +const getPostMenuBindings = makeAppBindingsSelector(AppBindingLocations.POST_MENU_ITEM); + function mapStateToProps(state: GlobalState, ownProps: Props) { const {post} = ownProps; @@ -54,6 +61,9 @@ function mapStateToProps(state: GlobalState, ownProps: Props) { const currentTeam = getCurrentTeam(state) || {}; const currentTeamUrl = `${getSiteURL()}/${currentTeam.name}`; + const apps = appsEnabled(state); + const appBindings = getPostMenuBindings(state); + return { channelIsArchived: isArchivedChannel(channel), components: state.plugins.components, @@ -64,12 +74,26 @@ function mapStateToProps(state: GlobalState, ownProps: Props) { canEdit: PostUtils.canEditPost(state, post, license, config, channel, userId), canDelete: PostUtils.canDeletePost(state, post, channel), currentTeamUrl, + appBindings, + appsEnabled: apps, + ...ownProps, }; } +type Actions = { + flagPost: (postId: string) => void; + unflagPost: (postId: string) => void; + setEditingPost: (postId?: string, commentCount?: number, refocusId?: string, title?: string, isRHS?: boolean) => void; + pinPost: (postId: string) => void; + unpinPost: (postId: string) => void; + openModal: (postId: any) => void; + markPostAsUnread: (post: Post) => void; + doAppCall: (call: AppCallRequest, type: AppCallType) => Promise; +} + function mapDispatchToProps(dispatch: Dispatch) { return { - actions: bindActionCreators({ + actions: bindActionCreators, Actions>({ flagPost, unflagPost, setEditingPost, @@ -77,6 +101,7 @@ function mapDispatchToProps(dispatch: Dispatch) { unpinPost, openModal, markPostAsUnread, + doAppCall, }, dispatch), }; } diff --git a/components/main_menu/main_menu.jsx b/components/main_menu/main_menu.jsx index fd37ffc98685..7b0bbf484a39 100644 --- a/components/main_menu/main_menu.jsx +++ b/components/main_menu/main_menu.jsx @@ -327,7 +327,7 @@ class MainMenu extends React.PureComponent { modalId={ModalIdentifiers.PLUGIN_MARKETPLACE} show={!this.props.mobile && this.props.enablePluginMarketplace} dialogType={MarketplaceModal} - text={formatMessage({id: 'navbar_dropdown.marketplace', defaultMessage: 'Plugin Marketplace'})} + text={formatMessage({id: 'navbar_dropdown.marketplace', defaultMessage: 'Marketplace'})} showUnread={!this.props.firstAdminVisitMarketplaceStatus} /> diff --git a/components/plugin_marketplace/__snapshots__/marketplace_modal.test.tsx.snap b/components/plugin_marketplace/__snapshots__/marketplace_modal.test.tsx.snap index dc754fd55a08..527c99097468 100644 --- a/components/plugin_marketplace/__snapshots__/marketplace_modal.test.tsx.snap +++ b/components/plugin_marketplace/__snapshots__/marketplace_modal.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`components/marketplace/ AllPlugins should render with no plugins 1`] = ` +exports[`components/marketplace/ AllListing should render with no plugins 1`] = `
@@ -19,9 +19,9 @@ exports[`components/marketplace/ AllPlugins should render with no plugins 1`] =
`; -exports[`components/marketplace/ AllPlugins should render with one plugin 1`] = ` +exports[`components/marketplace/ AllListing should render with one plugin 1`] = ` `; -exports[`components/marketplace/ AllPlugins should render with plugins 1`] = ` +exports[`components/marketplace/ AllListing should render with plugins 1`] = ` @@ -206,7 +206,7 @@ exports[`components/marketplace/ MarketplaceModal should render with error banne

@@ -233,7 +233,7 @@ exports[`components/marketplace/ MarketplaceModal should render with error banne onInput={[Function]} placeholder={ Object { - "defaultMessage": "Search Plugins", + "defaultMessage": "Search Marketplace", "id": "marketplace_modal.search", } } @@ -243,16 +243,16 @@ exports[`components/marketplace/ MarketplaceModal should render with error banne - @@ -277,7 +277,7 @@ exports[`components/marketplace/ MarketplaceModal should render with error banne exports[`components/marketplace/ MarketplaceModal should render with no plugins installed 1`] = ` @@ -288,7 +288,7 @@ exports[`components/marketplace/ MarketplaceModal should render with no plugins

@@ -315,7 +315,7 @@ exports[`components/marketplace/ MarketplaceModal should render with no plugins onInput={[Function]} placeholder={ Object { - "defaultMessage": "Search Plugins", + "defaultMessage": "Search Marketplace", "id": "marketplace_modal.search", } } @@ -325,16 +325,16 @@ exports[`components/marketplace/ MarketplaceModal should render with no plugins - @@ -359,7 +359,7 @@ exports[`components/marketplace/ MarketplaceModal should render with no plugins exports[`components/marketplace/ MarketplaceModal should render with plugins installed 1`] = ` @@ -370,7 +370,7 @@ exports[`components/marketplace/ MarketplaceModal should render with plugins ins

@@ -397,7 +397,7 @@ exports[`components/marketplace/ MarketplaceModal should render with plugins ins onInput={[Function]} placeholder={ Object { - "defaultMessage": "Search Plugins", + "defaultMessage": "Search Marketplace", "id": "marketplace_modal.search", } } @@ -407,16 +407,16 @@ exports[`components/marketplace/ MarketplaceModal should render with plugins ins - ; - filterPlugins(filter: string): Promise<{error?: Error}>; + fetchListing(localOnly?: boolean): Promise<{error?: Error}>; + filterListing(filter: string): Promise<{error?: Error}>; setFirstAdminVisitMarketplaceStatus(): Promise; } @@ -42,8 +42,8 @@ function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators, Actions>({ closeModal: () => closeModal(ModalIdentifiers.PLUGIN_MARKETPLACE), - fetchPlugins, - filterPlugins, + fetchListing, + filterListing, setFirstAdminVisitMarketplaceStatus, }, dispatch), }; diff --git a/components/plugin_marketplace/marketplace_item/__snapshots__/marketplace_item.test.tsx.snap b/components/plugin_marketplace/marketplace_item/__snapshots__/marketplace_item.test.tsx.snap deleted file mode 100644 index a4db38cadade..000000000000 --- a/components/plugin_marketplace/marketplace_item/__snapshots__/marketplace_item.test.tsx.snap +++ /dev/null @@ -1,1174 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/MarketplaceItem MarketplaceItem should render 1`] = ` - - - -`; - -exports[`components/MarketplaceItem MarketplaceItem should render installed plugin 1`] = ` - -
-
- -
- -
- - - -
- -
-
-`; - -exports[`components/MarketplaceItem MarketplaceItem should render with empty list of labels 1`] = ` - -
-
- -
- -
- -
- -
-
-`; - -exports[`components/MarketplaceItem MarketplaceItem should render with no plugin description 1`] = ` - -
-
- -
- -
- -
- -
-
-`; - -exports[`components/MarketplaceItem MarketplaceItem should render with no plugin icon 1`] = ` - - - -`; - -exports[`components/MarketplaceItem MarketplaceItem should render with one labels 1`] = ` - -
-
- -
- -
- -
- -
-
-`; - -exports[`components/MarketplaceItem MarketplaceItem should render with server error 1`] = ` - - - -`; - -exports[`components/MarketplaceItem MarketplaceItem should render with two labels 1`] = ` - -
-
- -
- -
- -
- -
-
-`; - -exports[`components/MarketplaceItem MarketplaceItem should render with update and release notes available 1`] = ` - -
-
- -
- -
- - - -
- -
-
-`; - -exports[`components/MarketplaceItem MarketplaceItem should render with update available 1`] = ` - -
-
- -
- -
- - - -
- -
-
-`; - -exports[`components/MarketplaceItem MarketplaceItem should render wtih no homepage url 1`] = ` - -
-
- -
-
- - name - - (1.0.0) - - - -

- test plugin -

-
- -
-
- -
- -
-
-`; - -exports[`components/MarketplaceItem UpdateConfirmationModal should add extra warning for major version change 1`] = ` - - } - message={ - Array [ -

- -

, -

- -

, -

- -

, - ] - } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={true} - title={ - - } -/> -`; - -exports[`components/MarketplaceItem UpdateConfirmationModal should add extra warning for major version change, even without release notes 1`] = ` - - } - message={ - Array [ -

- -

, -

- -

, -

- -

, - ] - } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={true} - title={ - - } -/> -`; - -exports[`components/MarketplaceItem UpdateConfirmationModal should avoid exception on invalid semver 1`] = `null`; - -exports[`components/MarketplaceItem UpdateConfirmationModal should render without release notes url 1`] = ` - - } - message={ - Array [ -

- -

, -

- -

, - ] - } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={true} - title={ - - } -/> -`; - -exports[`components/MarketplaceItem UpdateDetails should render with release notes url 1`] = ` - -
- - - Update available: - - - - - - 0.0.2 - - - - - - - - - Update - - - - -
-
-`; - -exports[`components/MarketplaceItem UpdateDetails should render without release notes url 1`] = ` - -
- - - Update available: - - - - - - 0.0.2 - - - - - - - - - Update - - - - -
-
-`; diff --git a/components/plugin_marketplace/marketplace_item/marketplace_item.tsx b/components/plugin_marketplace/marketplace_item/marketplace_item.tsx index f481bd5a42a8..c7886c3e6791 100644 --- a/components/plugin_marketplace/marketplace_item/marketplace_item.tsx +++ b/components/plugin_marketplace/marketplace_item/marketplace_item.tsx @@ -4,49 +4,14 @@ import React from 'react'; import classNames from 'classnames'; import {Tooltip} from 'react-bootstrap'; -import semver from 'semver'; -import {FormattedMessage} from 'react-intl'; +import type {MarketplaceLabel} from 'mattermost-redux/types/marketplace'; -import {Link} from 'react-router-dom'; - -import {MarketplaceLabel} from 'mattermost-redux/types/plugins'; - -import FormattedMarkdownMessage from 'components/formatted_markdown_message'; -import ConfirmModal from 'components/confirm_modal'; import OverlayTrigger from 'components/overlay_trigger'; -import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; import PluginIcon from 'components/widgets/icons/plugin_icon.jsx'; -import {localizeMessage} from 'utils/utils'; import {Constants} from 'utils/constants'; -type UpdateVersionProps = { - version: string; - releaseNotesUrl?: string; -}; - -// UpdateVersion renders the version text in the update details, linking out to release notes if available. -export const UpdateVersion = ({version, releaseNotesUrl}: UpdateVersionProps): JSX.Element => { - if (!releaseNotesUrl) { - return ( - - {version} - - ); - } - - return ( - - {version} - - ); -}; - // Label renders a tag showing a name and a description in a tooltip. // If a URL is provided, clicking on the tag will open the URL in a new tab. export const Label = ({name, description, url, color}: MarketplaceLabel): JSX.Element => { @@ -95,304 +60,23 @@ export const Label = ({name, description, url, color}: MarketplaceLabel): JSX.El return label; }; -export type UpdateDetailsProps = { - version: string; - releaseNotesUrl?: string; - installedVersion?: string; - isInstalling: boolean; - onUpdate: (e: React.MouseEvent) => void; -}; - -// UpdateDetails renders an inline update prompt for plugins, when available. -export const UpdateDetails = ({version, releaseNotesUrl, installedVersion, isInstalling, onUpdate}: UpdateDetailsProps): JSX.Element | null => { - if (!installedVersion || isInstalling) { - return null; - } - - let isUpdate = false; - try { - isUpdate = semver.gt(version, installedVersion); - } catch (e) { - // If we fail to parse the version, assume not an update; - } - - if (!isUpdate) { - return null; - } - - return ( -
- - {' '} - - {' - '} - - - - - -
- ); -}; - -export type UpdateConfirmationModalProps = { - show: boolean; - name: string; - version: string; - releaseNotesUrl?: string; - installedVersion?: string; - onUpdate: (checked: boolean) => void; - onCancel: (checked: boolean) => void; -}; - -// UpdateConfirmationModal prompts before allowing upgrade, specially handling major version changes. -export const UpdateConfirmationModal = ({show, name, version, installedVersion, releaseNotesUrl, onUpdate, onCancel}: UpdateConfirmationModalProps): JSX.Element | null => { - if (!installedVersion) { - return null; - } - - let isUpdate = false; - try { - isUpdate = semver.gt(version, installedVersion); - } catch (e) { - // If we fail to parse the version, assume not an update; - } - - if (!isUpdate) { - return null; - } - - const messages = [( -

- -

- )]; - - if (releaseNotesUrl) { - messages.push( -

- -

, - ); - } else { - messages.push( -

- -

, - ); - } - - let sameMajorVersion = false; - try { - sameMajorVersion = semver.major(version) === semver.major(installedVersion); - } catch (e) { - // If we fail to parse the version, assume a potentially breaking change. - // In practice, this won't happen since we already tried to parse the version above. - } - - if (!sameMajorVersion) { - if (releaseNotesUrl) { - messages.push( -

- -

, - ); - } else { - messages.push( -

- -

, - ); - } - } - - return ( - - } - message={messages} - confirmButtonText={ - - } - onConfirm={onUpdate} - onCancel={onCancel} - /> - ); -}; - export type MarketplaceItemProps = { id: string; name: string; description?: string; - version: string; - homepageUrl?: string; - releaseNotesUrl?: string; - labels?: MarketplaceLabel[]; iconData?: string; - installedVersion?: string; - installing: boolean; - error?: string; - isDefaultMarketplace: boolean; - trackEvent: (category: string, event: string, props?: any) => void; + labels?: MarketplaceLabel[]; + homepageUrl?: string; - actions: { - installPlugin: (category: string, event: string) => void; - closeMarketplaceModal: () => void; - }; -}; + error?: string; -type MarketplaceItemState = { - showUpdateConfirmationModal: boolean; + button: JSX.Element; + updateDetails: JSX.Element | null; + versionLabel: JSX.Element| null; }; -export default class MarketplaceItem extends React.PureComponent { - constructor(props: MarketplaceItemProps) { - super(props); - - this.state = { - showUpdateConfirmationModal: false, - }; - } - - trackEvent = (eventName: string, allowDetail = true): void => { - if (this.props.isDefaultMarketplace && allowDetail) { - this.props.trackEvent('plugins', eventName, { - plugin_id: this.props.id, - version: this.props.version, - installed_version: this.props.installedVersion, - }); - } else { - this.props.trackEvent('plugins', eventName); - } - } - - onInstall = (): void => { - this.trackEvent('ui_marketplace_download'); - this.props.actions.installPlugin(this.props.id, this.props.version); - } - - showUpdateConfirmationModal = (): void => { - this.setState({showUpdateConfirmationModal: true}); - } - - hideUpdateConfirmationModal = (): void => { - this.setState({showUpdateConfirmationModal: false}); - } - - onUpdate = (): void => { - this.trackEvent('ui_marketplace_download_update'); - - this.hideUpdateConfirmationModal(); - this.props.actions.installPlugin(this.props.id, this.props.version); - } - - onConfigure = (): void => { - this.trackEvent('ui_marketplace_configure', false); - - this.props.actions.closeMarketplaceModal(); - } - - getItemButton(): JSX.Element { - let actionButton = ( - - ); - if (this.props.error) { - actionButton = ( - - ); - } - - let button = ( - - ); - - if (this.props.installedVersion !== '' && !this.props.installing && !this.props.error) { - button = ( - - - - ); - } - - return button; - } - +export default class MarketplaceItem extends React.PureComponent { render(): JSX.Element { - let versionLabel = `(${this.props.version})`; - if (this.props.installedVersion !== '') { - versionLabel = `(${this.props.installedVersion})`; - } - let pluginIcon; if (this.props.iconData) { pluginIcon = ( @@ -421,7 +105,7 @@ export default class MarketplaceItem extends React.PureComponent {this.props.name} - {versionLabel} + {this.props.versionLabel} ); @@ -486,26 +170,12 @@ export default class MarketplaceItem extends React.PureComponent {pluginDetails} - + {this.props.updateDetails} +
- {this.getItemButton()} + {this.props.button}
- ); diff --git a/components/plugin_marketplace/marketplace_item/marketplace_item_app/__snapshots__/marketplace_item_app.test.tsx.snap b/components/plugin_marketplace/marketplace_item/marketplace_item_app/__snapshots__/marketplace_item_app.test.tsx.snap new file mode 100644 index 000000000000..f1dd0cc9ab17 --- /dev/null +++ b/components/plugin_marketplace/marketplace_item/marketplace_item_app/__snapshots__/marketplace_item_app.test.tsx.snap @@ -0,0 +1,370 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/MarketplaceItemApp MarketplaceItem should render 1`] = ` + + + + + + + } + description="test plugin" + homepageUrl="http://example.com" + id="id" + installed={false} + installing={false} + name="name" + trackEvent={[MockFunction]} + updateDetails={null} + versionLabel={null} + /> + +`; + +exports[`components/MarketplaceItemApp MarketplaceItem should render installed app 1`] = ` + + + + + } + description="test plugin" + homepageUrl="http://example.com" + id="id" + installed={true} + installing={false} + name="name" + trackEvent={[MockFunction]} + updateDetails={null} + versionLabel={null} + /> + +`; + +exports[`components/MarketplaceItemApp MarketplaceItem should render with empty list of labels 1`] = ` + + + + + + + } + description="test plugin" + homepageUrl="http://example.com" + id="id" + installed={false} + installing={false} + labels={Array []} + name="name" + trackEvent={[MockFunction]} + updateDetails={null} + versionLabel={null} + /> + +`; + +exports[`components/MarketplaceItemApp MarketplaceItem should render with no homepage url 1`] = ` + + + + + + + } + description="test plugin" + id="id" + installed={false} + installing={false} + name="name" + trackEvent={[MockFunction]} + updateDetails={null} + versionLabel={null} + /> + +`; + +exports[`components/MarketplaceItemApp MarketplaceItem should render with no plugin description 1`] = ` + + + + + + + } + homepageUrl="http://example.com" + id="id" + installed={false} + installing={false} + name="name" + trackEvent={[MockFunction]} + updateDetails={null} + versionLabel={null} + /> + +`; + +exports[`components/MarketplaceItemApp MarketplaceItem should render with one labels 1`] = ` + + + + + + + } + description="test plugin" + homepageUrl="http://example.com" + id="id" + installed={false} + installing={false} + labels={ + Array [ + Object { + "description": "some description", + "name": "someName", + "url": "http://example.com/info", + }, + ] + } + name="name" + trackEvent={[MockFunction]} + updateDetails={null} + versionLabel={null} + /> + +`; + +exports[`components/MarketplaceItemApp MarketplaceItem should render with server error 1`] = ` + + + + + + + } + description="test plugin" + error="An error occurred." + homepageUrl="http://example.com" + id="id" + installed={false} + installing={false} + name="name" + trackEvent={[MockFunction]} + updateDetails={null} + versionLabel={null} + /> + +`; + +exports[`components/MarketplaceItemApp MarketplaceItem should render with two labels 1`] = ` + + + + + + + } + description="test plugin" + homepageUrl="http://example.com" + id="id" + installed={false} + installing={false} + labels={ + Array [ + Object { + "description": "some description", + "name": "someName", + "url": "http://example.com/info", + }, + Object { + "description": "some description2", + "name": "someName2", + "url": "http://example.com/info2", + }, + ] + } + name="name" + trackEvent={[MockFunction]} + updateDetails={null} + versionLabel={null} + /> + +`; + +exports[`components/MarketplaceItemApp MarketplaceItem when installing 1`] = ` + + + + + + + } + description="test plugin" + homepageUrl="http://example.com" + id="id" + installed={false} + installing={false} + isInstalling={true} + name="name" + trackEvent={[MockFunction]} + updateDetails={null} + versionLabel={null} + /> + +`; diff --git a/components/plugin_marketplace/marketplace_item/marketplace_item_app/index.ts b/components/plugin_marketplace/marketplace_item/marketplace_item_app/index.ts new file mode 100644 index 000000000000..a96654d23e26 --- /dev/null +++ b/components/plugin_marketplace/marketplace_item/marketplace_item_app/index.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; + +import {GenericAction} from 'mattermost-redux/types/actions'; + +import {GlobalState} from 'types/store'; + +import {installApp} from 'actions/marketplace'; +import {closeModal} from 'actions/views/modals'; +import {trackEvent} from 'actions/telemetry_actions.jsx'; +import {getInstalling, getError} from 'selectors/views/marketplace'; +import {ModalIdentifiers} from 'utils/constants'; + +import MarketplaceItemApp, {MarketplaceItemAppProps} from './marketplace_item_app'; + +type Props = { + id: string; +} + +function mapStateToProps(state: GlobalState, props: Props) { + const installing = getInstalling(state, props.id); + const error = getError(state, props.id); + + return { + installing, + error, + trackEvent, + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + installApp, + closeMarketplaceModal: () => closeModal(ModalIdentifiers.PLUGIN_MARKETPLACE), + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(MarketplaceItemApp); diff --git a/components/plugin_marketplace/marketplace_item/marketplace_item_app/marketplace_item_app.test.tsx b/components/plugin_marketplace/marketplace_item/marketplace_item_app/marketplace_item_app.test.tsx new file mode 100644 index 000000000000..8b45538417ff --- /dev/null +++ b/components/plugin_marketplace/marketplace_item/marketplace_item_app/marketplace_item_app.test.tsx @@ -0,0 +1,165 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import MarketplaceItemApp, {MarketplaceItemAppProps} from './marketplace_item_app'; + +describe('components/MarketplaceItemApp', () => { + describe('MarketplaceItem', () => { + const baseProps: MarketplaceItemAppProps = { + id: 'id', + name: 'name', + description: 'test plugin', + homepageUrl: 'http://example.com', + installed: false, + installing: false, + trackEvent: jest.fn(() => {}), + actions: { + installApp: jest.fn(async () => Promise.resolve(true)), + closeMarketplaceModal: jest.fn(() => {}), + }, + }; + + test('should render', () => { + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should render with no plugin description', () => { + const props = {...baseProps}; + delete props.description; + + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should render with no homepage url', () => { + const props = {...baseProps}; + delete props.homepageUrl; + + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should render with server error', () => { + const props = { + ...baseProps, + error: 'An error occurred.', + }; + + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + it('when installing', () => { + const props = { + ...baseProps, + isInstalling: true, + }; + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should render installed app', () => { + const props = { + ...baseProps, + installed: true, + }; + + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should render with empty list of labels', () => { + const props = { + ...baseProps, + labels: [], + }; + + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should render with one labels', () => { + const props = { + ...baseProps, + labels: [ + { + name: 'someName', + description: 'some description', + url: 'http://example.com/info', + }, + ], + }; + + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should render with two labels', () => { + const props = { + ...baseProps, + labels: [ + { + name: 'someName', + description: 'some description', + url: 'http://example.com/info', + }, { + name: 'someName2', + description: 'some description2', + url: 'http://example.com/info2', + }, + ], + }; + + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + describe('install should trigger track event and close modal', () => { + const props = { + ...baseProps, + isDefaultMarketplace: true, + }; + + const wrapper = shallow( + , + ); + + wrapper.instance().onInstall(); + expect(props.trackEvent).toBeCalledWith('plugins', 'ui_marketplace_install_app', { + app_id: 'id', + }); + expect(props.actions.installApp).toHaveBeenCalledWith('id'); + }); + }); +}); diff --git a/components/plugin_marketplace/marketplace_item/marketplace_item_app/marketplace_item_app.tsx b/components/plugin_marketplace/marketplace_item/marketplace_item_app/marketplace_item_app.tsx new file mode 100644 index 000000000000..ff3a7669bd6d --- /dev/null +++ b/components/plugin_marketplace/marketplace_item/marketplace_item_app/marketplace_item_app.tsx @@ -0,0 +1,109 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {FormattedMessage} from 'react-intl'; + +import type {MarketplaceLabel} from 'mattermost-redux/types/marketplace'; + +import MarketplaceItem from '../marketplace_item'; +import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; + +import {localizeMessage} from 'utils/utils'; + +export type MarketplaceItemAppProps = { + id: string; + name: string; + description?: string; + homepageUrl?: string; + + installed: boolean; + labels?: MarketplaceLabel[]; + + installing: boolean; + error?: string; + + trackEvent: (category: string, event: string, props?: unknown) => void; + + actions: { + installApp: (id: string) => Promise; + closeMarketplaceModal: () => void; + }; +}; + +export default class MarketplaceItemApp extends React.PureComponent { + onInstall = (): void => { + this.props.trackEvent('plugins', 'ui_marketplace_install_app', { + app_id: this.props.id, + }); + + this.props.actions.installApp(this.props.id).then((res) => { + if (res) { + this.props.actions.closeMarketplaceModal(); + } + }); + } + + getItemButton(): JSX.Element { + if (this.props.installed && !this.props.installing && !this.props.error) { + return ( + + ); + } + + let actionButton: JSX.Element; + if (this.props.error) { + actionButton = ( + + ); + } else { + actionButton = ( + + ); + } + + return ( + + ); + } + + render(): JSX.Element { + return ( + <> + + + ); + } +} diff --git a/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/__snapshots__/marketplace_item_plugin.test.tsx.snap b/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/__snapshots__/marketplace_item_plugin.test.tsx.snap new file mode 100644 index 000000000000..ae6d969f67fc --- /dev/null +++ b/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/__snapshots__/marketplace_item_plugin.test.tsx.snap @@ -0,0 +1,982 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/MarketplaceItemPlugin MarketplaceItem should render 1`] = ` + + + + + + + } + description="test plugin" + homepageUrl="http://example.com" + iconData="icon" + id="id" + installedVersion="" + installing={false} + isDefaultMarketplace={true} + name="name" + trackEvent={[MockFunction]} + updateDetails={ + + } + version="1.0.0" + versionLabel={ + + (1.0.0) + + } + /> + + +`; + +exports[`components/MarketplaceItemPlugin MarketplaceItem should render installed plugin 1`] = ` + + + + + } + description="test plugin" + homepageUrl="http://example.com" + iconData="icon" + id="id" + installedVersion="1.0.0" + installing={false} + isDefaultMarketplace={true} + name="name" + trackEvent={[MockFunction]} + updateDetails={ + + } + version="1.0.0" + versionLabel={ + + (1.0.0) + + } + /> + + +`; + +exports[`components/MarketplaceItemPlugin MarketplaceItem should render with empty list of labels 1`] = ` + + + + + + + } + description="test plugin" + homepageUrl="http://example.com" + iconData="icon" + id="id" + installedVersion="" + installing={false} + isDefaultMarketplace={true} + labels={Array []} + name="name" + trackEvent={[MockFunction]} + updateDetails={ + + } + version="1.0.0" + versionLabel={ + + (1.0.0) + + } + /> + + +`; + +exports[`components/MarketplaceItemPlugin MarketplaceItem should render with no homepage url 1`] = ` + + + + + + + } + description="test plugin" + iconData="icon" + id="id" + installedVersion="" + installing={false} + isDefaultMarketplace={true} + name="name" + trackEvent={[MockFunction]} + updateDetails={ + + } + version="1.0.0" + versionLabel={ + + (1.0.0) + + } + /> + + +`; + +exports[`components/MarketplaceItemPlugin MarketplaceItem should render with no plugin description 1`] = ` + + + + + + + } + homepageUrl="http://example.com" + iconData="icon" + id="id" + installedVersion="" + installing={false} + isDefaultMarketplace={true} + name="name" + trackEvent={[MockFunction]} + updateDetails={ + + } + version="1.0.0" + versionLabel={ + + (1.0.0) + + } + /> + + +`; + +exports[`components/MarketplaceItemPlugin MarketplaceItem should render with no plugin icon 1`] = ` + + + + + + + } + description="test plugin" + homepageUrl="http://example.com" + id="id" + installedVersion="" + installing={false} + isDefaultMarketplace={true} + name="name" + trackEvent={[MockFunction]} + updateDetails={ + + } + version="1.0.0" + versionLabel={ + + (1.0.0) + + } + /> + + +`; + +exports[`components/MarketplaceItemPlugin MarketplaceItem should render with one labels 1`] = ` + + + + + + + } + description="test plugin" + homepageUrl="http://example.com" + iconData="icon" + id="id" + installedVersion="" + installing={false} + isDefaultMarketplace={true} + labels={ + Array [ + Object { + "description": "some description", + "name": "someName", + "url": "http://example.com/info", + }, + ] + } + name="name" + trackEvent={[MockFunction]} + updateDetails={ + + } + version="1.0.0" + versionLabel={ + + (1.0.0) + + } + /> + + +`; + +exports[`components/MarketplaceItemPlugin MarketplaceItem should render with server error 1`] = ` + + + + + + + } + description="test plugin" + error="An error occurred." + homepageUrl="http://example.com" + iconData="icon" + id="id" + installedVersion="" + installing={false} + isDefaultMarketplace={true} + name="name" + trackEvent={[MockFunction]} + updateDetails={ + + } + version="1.0.0" + versionLabel={ + + (1.0.0) + + } + /> + + +`; + +exports[`components/MarketplaceItemPlugin MarketplaceItem should render with two labels 1`] = ` + + + + + + + } + description="test plugin" + homepageUrl="http://example.com" + iconData="icon" + id="id" + installedVersion="" + installing={false} + isDefaultMarketplace={true} + labels={ + Array [ + Object { + "description": "some description", + "name": "someName", + "url": "http://example.com/info", + }, + Object { + "description": "some description2", + "name": "someName2", + "url": "http://example.com/info2", + }, + ] + } + name="name" + trackEvent={[MockFunction]} + updateDetails={ + + } + version="1.0.0" + versionLabel={ + + (1.0.0) + + } + /> + + +`; + +exports[`components/MarketplaceItemPlugin MarketplaceItem should render with update and release notes available 1`] = ` + + + + + } + description="test plugin" + homepageUrl="http://example.com" + iconData="icon" + id="id" + installedVersion="0.9.9" + installing={false} + isDefaultMarketplace={true} + name="name" + releaseNotesUrl="http://example.com/release" + trackEvent={[MockFunction]} + updateDetails={ + + } + version="1.0.0" + versionLabel={ + + (0.9.9) + + } + /> + + +`; + +exports[`components/MarketplaceItemPlugin MarketplaceItem should render with update available 1`] = ` + + + + + } + description="test plugin" + homepageUrl="http://example.com" + iconData="icon" + id="id" + installedVersion="0.9.9" + installing={false} + isDefaultMarketplace={true} + name="name" + trackEvent={[MockFunction]} + updateDetails={ + + } + version="1.0.0" + versionLabel={ + + (0.9.9) + + } + /> + + +`; + +exports[`components/MarketplaceItemPlugin UpdateConfirmationModal should add extra warning for major version change 1`] = ` + + } + message={ + Array [ +

+ +

, +

+ +

, +

+ +

, + ] + } + modalClass="" + onCancel={[Function]} + onConfirm={[Function]} + show={true} + title={ + + } +/> +`; + +exports[`components/MarketplaceItemPlugin UpdateConfirmationModal should add extra warning for major version change, even without release notes 1`] = ` + + } + message={ + Array [ +

+ +

, +

+ +

, +

+ +

, + ] + } + modalClass="" + onCancel={[Function]} + onConfirm={[Function]} + show={true} + title={ + + } +/> +`; + +exports[`components/MarketplaceItemPlugin UpdateConfirmationModal should avoid exception on invalid semver 1`] = `null`; + +exports[`components/MarketplaceItemPlugin UpdateConfirmationModal should render without release notes url 1`] = ` + + } + message={ + Array [ +

+ +

, +

+ +

, + ] + } + modalClass="" + onCancel={[Function]} + onConfirm={[Function]} + show={true} + title={ + + } +/> +`; + +exports[`components/MarketplaceItemPlugin UpdateDetails should render with release notes url 1`] = ` + +
+ + + Update available: + + + + + + 0.0.2 + + + - + + + + + Update + + + + +
+
+`; + +exports[`components/MarketplaceItemPlugin UpdateDetails should render without release notes url 1`] = ` + +
+ + + Update available: + + + + + + 0.0.2 + + + - + + + + + Update + + + + +
+
+`; diff --git a/components/plugin_marketplace/marketplace_item/index.ts b/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/index.ts similarity index 87% rename from components/plugin_marketplace/marketplace_item/index.ts rename to components/plugin_marketplace/marketplace_item/marketplace_item_plugin/index.ts index c711863a341b..012b393874e8 100644 --- a/components/plugin_marketplace/marketplace_item/index.ts +++ b/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/index.ts @@ -5,16 +5,17 @@ import {connect} from 'react-redux'; import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {GlobalState} from 'mattermost-redux/types/store'; import {GenericAction} from 'mattermost-redux/types/actions'; +import {GlobalState} from 'types/store'; + import {installPlugin} from 'actions/marketplace'; import {closeModal} from 'actions/views/modals'; import {ModalIdentifiers} from 'utils/constants'; import {getInstalling, getError} from 'selectors/views/marketplace'; import {trackEvent} from 'actions/telemetry_actions.jsx'; -import MarketplaceItem, {MarketplaceItemProps} from './marketplace_item'; +import MarketplaceItemPlugin, {MarketplaceItemPluginProps} from './marketplace_item_plugin'; type Props = { id: string; @@ -35,11 +36,11 @@ function mapStateToProps(state: GlobalState, props: Props) { function mapDispatchToProps(dispatch: Dispatch) { return { - actions: bindActionCreators({ + actions: bindActionCreators({ installPlugin, closeMarketplaceModal: () => closeModal(ModalIdentifiers.PLUGIN_MARKETPLACE), }, dispatch), }; } -export default connect(mapStateToProps, mapDispatchToProps)(MarketplaceItem); +export default connect(mapStateToProps, mapDispatchToProps)(MarketplaceItemPlugin); diff --git a/components/plugin_marketplace/marketplace_item/marketplace_item.test.tsx b/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/marketplace_item_plugin.test.tsx similarity index 83% rename from components/plugin_marketplace/marketplace_item/marketplace_item.test.tsx rename to components/plugin_marketplace/marketplace_item/marketplace_item_plugin/marketplace_item_plugin.test.tsx index 6eea145f1a2a..7cb2798f64d3 100644 --- a/components/plugin_marketplace/marketplace_item/marketplace_item.test.tsx +++ b/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/marketplace_item_plugin.test.tsx @@ -7,9 +7,9 @@ import {shallow} from 'enzyme'; import ConfirmModal from 'components/confirm_modal'; import {mountWithIntl as mount} from 'tests/helpers/intl-test-helper'; -import MarketplaceItem, {UpdateDetails, UpdateDetailsProps, UpdateConfirmationModal, UpdateConfirmationModalProps, MarketplaceItemProps} from './marketplace_item'; +import MarketplaceItemPlugin, {UpdateDetails, UpdateDetailsProps, UpdateConfirmationModal, UpdateConfirmationModalProps, MarketplaceItemPluginProps} from './marketplace_item_plugin'; -describe('components/MarketplaceItem', () => { +describe('components/MarketplaceItemPlugin', () => { describe('UpdateDetails', () => { const baseProps: UpdateDetailsProps = { version: '0.0.2', @@ -194,7 +194,7 @@ describe('components/MarketplaceItem', () => { }); describe('MarketplaceItem', () => { - const baseProps: MarketplaceItemProps = { + const baseProps: MarketplaceItemPluginProps = { id: 'id', name: 'name', description: 'test plugin', @@ -204,16 +204,16 @@ describe('components/MarketplaceItem', () => { iconData: 'icon', installing: false, isDefaultMarketplace: true, - trackEvent: jest.fn(() => {}), // eslint-disable-line no-empty-function + trackEvent: jest.fn(() => {}), actions: { - installPlugin: jest.fn(() => {}), // eslint-disable-line no-empty-function - closeMarketplaceModal: jest.fn(() => {}), // eslint-disable-line no-empty-function + installPlugin: jest.fn(() => {}), + closeMarketplaceModal: jest.fn(() => {}), }, }; test('should render', () => { - const wrapper = shallow( - , + const wrapper = shallow( + , ); expect(wrapper).toMatchSnapshot(); @@ -224,7 +224,7 @@ describe('components/MarketplaceItem', () => { delete props.description; const wrapper = shallow( - , + , ); expect(wrapper).toMatchSnapshot(); @@ -234,19 +234,19 @@ describe('components/MarketplaceItem', () => { const props = {...baseProps}; delete props.iconData; - const wrapper = shallow( - , + const wrapper = shallow( + , ); expect(wrapper).toMatchSnapshot(); }); - test('should render wtih no homepage url', () => { + test('should render with no homepage url', () => { const props = {...baseProps}; delete props.homepageUrl; - const wrapper = shallow( - , + const wrapper = shallow( + , ); expect(wrapper).toMatchSnapshot(); @@ -258,8 +258,8 @@ describe('components/MarketplaceItem', () => { error: 'An error occurred.', }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); expect(wrapper).toMatchSnapshot(); @@ -271,8 +271,8 @@ describe('components/MarketplaceItem', () => { installedVersion: '1.0.0', }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); expect(wrapper).toMatchSnapshot(); @@ -284,8 +284,8 @@ describe('components/MarketplaceItem', () => { installedVersion: '0.9.9', }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); expect(wrapper).toMatchSnapshot(); @@ -298,8 +298,8 @@ describe('components/MarketplaceItem', () => { releaseNotesUrl: 'http://example.com/release', }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); expect(wrapper).toMatchSnapshot(); @@ -311,8 +311,8 @@ describe('components/MarketplaceItem', () => { labels: [], }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); expect(wrapper).toMatchSnapshot(); @@ -330,8 +330,8 @@ describe('components/MarketplaceItem', () => { ], }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); expect(wrapper).toMatchSnapshot(); @@ -353,8 +353,8 @@ describe('components/MarketplaceItem', () => { ], }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); expect(wrapper).toMatchSnapshot(); @@ -367,8 +367,8 @@ describe('components/MarketplaceItem', () => { isDefaultMarketplace: true, }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.instance().onInstall(); @@ -387,8 +387,8 @@ describe('components/MarketplaceItem', () => { isDefaultMarketplace: true, }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.instance().onUpdate(); @@ -407,8 +407,8 @@ describe('components/MarketplaceItem', () => { isDefaultMarketplace: true, }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.instance().onConfigure(); @@ -423,8 +423,8 @@ describe('components/MarketplaceItem', () => { isDefaultMarketplace: false, }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.instance().onInstall(); @@ -439,8 +439,8 @@ describe('components/MarketplaceItem', () => { isDefaultMarketplace: false, }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.instance().onUpdate(); @@ -455,8 +455,8 @@ describe('components/MarketplaceItem', () => { isDefaultMarketplace: false, }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.instance().onConfigure(); diff --git a/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/marketplace_item_plugin.tsx b/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/marketplace_item_plugin.tsx new file mode 100644 index 000000000000..0840be3ff565 --- /dev/null +++ b/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/marketplace_item_plugin.tsx @@ -0,0 +1,378 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import classNames from 'classnames'; +import semver from 'semver'; + +import {FormattedMessage} from 'react-intl'; + +import {Link} from 'react-router-dom'; + +import type {MarketplaceLabel} from 'mattermost-redux/types/marketplace'; + +import MarketplaceItem from '../marketplace_item'; + +import FormattedMarkdownMessage from 'components/formatted_markdown_message'; +import ConfirmModal from 'components/confirm_modal'; +import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; + +import {localizeMessage} from 'utils/utils'; + +type UpdateVersionProps = { + version: string; + releaseNotesUrl?: string; +}; + +// UpdateVersion renders the version text in the update details, linking out to release notes if available. +export const UpdateVersion = ({version, releaseNotesUrl}: UpdateVersionProps): JSX.Element => { + if (!releaseNotesUrl) { + return ( + + {version} + + ); + } + + return ( + + {version} + + ); +}; + +export type UpdateDetailsProps = { + version: string; + releaseNotesUrl?: string; + installedVersion?: string; + isInstalling: boolean; + onUpdate: (e: React.MouseEvent) => void; +}; + +// UpdateDetails renders an inline update prompt for plugins, when available. +export const UpdateDetails = ({version, releaseNotesUrl, installedVersion, isInstalling, onUpdate}: UpdateDetailsProps): JSX.Element | null => { + if (!installedVersion || isInstalling) { + return null; + } + + let isUpdate = false; + try { + isUpdate = semver.gt(version, installedVersion); + } catch (e) { + // If we fail to parse the version, assume not an update; + } + + if (!isUpdate) { + return null; + } + + return ( +
+ + {' '} + + {' - '} + + + + + +
+ ); +}; + +export type UpdateConfirmationModalProps = { + show: boolean; + name: string; + version: string; + releaseNotesUrl?: string; + installedVersion?: string; + onUpdate: (checked: boolean) => void; + onCancel: (checked: boolean) => void; +}; + +// UpdateConfirmationModal prompts before allowing upgrade, specially handling major version changes. +export const UpdateConfirmationModal = ({show, name, version, installedVersion, releaseNotesUrl, onUpdate, onCancel}: UpdateConfirmationModalProps): JSX.Element | null => { + if (!installedVersion) { + return null; + } + + let isUpdate = false; + try { + isUpdate = semver.gt(version, installedVersion); + } catch (e) { + // If we fail to parse the version, assume not an update; + } + + if (!isUpdate) { + return null; + } + + const messages = [( +

+ +

+ )]; + + if (releaseNotesUrl) { + messages.push( +

+ +

, + ); + } else { + messages.push( +

+ +

, + ); + } + + let sameMajorVersion = false; + try { + sameMajorVersion = semver.major(version) === semver.major(installedVersion); + } catch (e) { + // If we fail to parse the version, assume a potentially breaking change. + // In practice, this won't happen since we already tried to parse the version above. + } + + if (!sameMajorVersion) { + if (releaseNotesUrl) { + messages.push( +

+ +

, + ); + } else { + messages.push( +

+ +

, + ); + } + } + + return ( + + } + message={messages} + confirmButtonText={ + + } + onConfirm={onUpdate} + onCancel={onCancel} + /> + ); +}; + +export type MarketplaceItemPluginProps = { + id: string; + name: string; + description?: string; + version: string; + homepageUrl?: string; + releaseNotesUrl?: string; + labels?: MarketplaceLabel[]; + iconData?: string; + installedVersion?: string; + installing: boolean; + error?: string; + isDefaultMarketplace: boolean; + trackEvent: (category: string, event: string, props?: unknown) => void; + + actions: { + installPlugin: (category: string, event: string) => void; + closeMarketplaceModal: () => void; + }; +}; + +type MarketplaceItemState = { + showUpdateConfirmationModal: boolean; +}; + +export default class MarketplaceItemPlugin extends React.PureComponent { + constructor(props: MarketplaceItemPluginProps) { + super(props); + + this.state = { + showUpdateConfirmationModal: false, + }; + } + + trackEvent = (eventName: string, allowDetail = true): void => { + if (this.props.isDefaultMarketplace && allowDetail) { + this.props.trackEvent('plugins', eventName, { + plugin_id: this.props.id, + version: this.props.version, + installed_version: this.props.installedVersion, + }); + } else { + this.props.trackEvent('plugins', eventName); + } + } + + showUpdateConfirmationModal = (): void => { + this.setState({showUpdateConfirmationModal: true}); + } + + hideUpdateConfirmationModal = (): void => { + this.setState({showUpdateConfirmationModal: false}); + } + + onInstall = (): void => { + this.trackEvent('ui_marketplace_download'); + this.props.actions.installPlugin(this.props.id, this.props.version); + } + + onConfigure = (): void => { + this.trackEvent('ui_marketplace_configure', false); + + this.props.actions.closeMarketplaceModal(); + } + + onUpdate = (): void => { + this.trackEvent('ui_marketplace_download_update'); + + this.hideUpdateConfirmationModal(); + this.props.actions.installPlugin(this.props.id, this.props.version); + } + + getItemButton(): JSX.Element { + if (this.props.installedVersion !== '' && !this.props.installing && !this.props.error) { + return ( + + + + ); + } + + let actionButton: JSX.Element; + if (this.props.error) { + actionButton = ( + + ); + } else { + actionButton = ( + + ); + } + + return ( + + ); + } + + render(): JSX.Element { + let version = `(${this.props.version})`; + if (this.props.installedVersion !== '') { + version = `(${this.props.installedVersion})`; + } + + const versionLabel = {version}; + + const updateDetails = ( + + ); + + return ( + <> + + + + ); + } +} diff --git a/components/plugin_marketplace/marketplace_list/__snapshots__/marketplace_list.test.tsx.snap b/components/plugin_marketplace/marketplace_list/__snapshots__/marketplace_list.test.tsx.snap index 0f1469c7101c..660d25551683 100644 --- a/components/plugin_marketplace/marketplace_list/__snapshots__/marketplace_list.test.tsx.snap +++ b/components/plugin_marketplace/marketplace_list/__snapshots__/marketplace_list.test.tsx.snap @@ -4,7 +4,7 @@ exports[`components/marketplace/marketplace_list should render with multiple plu
- - - - - - - - - - - - - - - { it('should render with multiple plugins', () => { const wrapper = shallow( { it('should set page to 0 when list of plugins changed', () => { const wrapper = shallow( , ); wrapper.setState({page: 10}); - wrapper.setProps({plugins: [samplePlugin]}); + wrapper.setProps({listing: [samplePlugin]}); expect(wrapper.state().page).toEqual(0); }); diff --git a/components/plugin_marketplace/marketplace_list/marketplace_list.tsx b/components/plugin_marketplace/marketplace_list/marketplace_list.tsx index 360a3625cec6..f14349c0af2a 100644 --- a/components/plugin_marketplace/marketplace_list/marketplace_list.tsx +++ b/components/plugin_marketplace/marketplace_list/marketplace_list.tsx @@ -3,16 +3,18 @@ import React from 'react'; -import {MarketplacePlugin} from 'mattermost-redux/types/plugins'; +import type {MarketplaceApp, MarketplacePlugin} from 'mattermost-redux/types/marketplace'; +import {isPlugin, getName} from 'mattermost-redux/utils/marketplace'; -import MarketplaceItem from '../marketplace_item'; +import MarketplaceItemPlugin from '../marketplace_item/marketplace_item_plugin'; +import MarketplaceItemApp from '../marketplace_item/marketplace_item_app'; import NavigationRow from './navigation_row'; -const PLUGINS_PER_PAGE = 15; +const ITEMS_PER_PAGE = 15; type MarketplaceListProps = { - plugins: MarketplacePlugin[]; + listing: Array; }; type MarketplaceListState = { @@ -21,7 +23,7 @@ type MarketplaceListState = { export default class MarketplaceList extends React.PureComponent { static getDerivedStateFromProps(props: MarketplaceListProps, state: MarketplaceListState): MarketplaceListState | null { - if (state.page > 0 && props.plugins.length < PLUGINS_PER_PAGE) { + if (state.page > 0 && props.listing.length < ITEMS_PER_PAGE) { return {page: 0}; } @@ -49,30 +51,52 @@ export default class MarketplaceList extends React.PureComponent { + return getName(a).localeCompare(getName(b)); + }); + + const itemsToDisplay = this.props.listing.slice(pageStart, pageEnd); return (
- {pluginsToDisplay.map((p) => ( - - ))} + {itemsToDisplay.map((i) => { + if (isPlugin(i)) { + return ( + + ); + } + + return ( + + ); + }) + } diff --git a/components/plugin_marketplace/marketplace_modal.test.tsx b/components/plugin_marketplace/marketplace_modal.test.tsx index 3d30068cb65c..357ace5b06b1 100644 --- a/components/plugin_marketplace/marketplace_modal.test.tsx +++ b/components/plugin_marketplace/marketplace_modal.test.tsx @@ -4,11 +4,12 @@ import React from 'react'; import {shallow} from 'enzyme'; -import {AuthorType, MarketplacePlugin, PluginStatusRedux, ReleaseStage} from 'mattermost-redux/types/plugins'; +import {AuthorType, MarketplacePlugin, ReleaseStage} from 'mattermost-redux/types/marketplace'; +import type {PluginStatusRedux} from 'mattermost-redux/types/plugins'; import {trackEvent} from 'actions/telemetry_actions.jsx'; -import {AllPlugins, InstalledPlugins, MarketplaceModal, MarketplaceModalProps} from './marketplace_modal'; +import {AllListing, InstalledListing, MarketplaceModal, MarketplaceModalProps} from './marketplace_modal'; jest.mock('actions/telemetry_actions.jsx', () => { const original = jest.requireActual('actions/telemetry_actions.jsx'); @@ -51,24 +52,24 @@ describe('components/marketplace/', () => { installed_version: '1.0.3', }; - describe('AllPlugins', () => { + describe('AllListing', () => { it('should render with no plugins', () => { const wrapper = shallow( - , + , ); expect(wrapper).toMatchSnapshot(); }); it('should render with one plugin', () => { const wrapper = shallow( - , + , ); expect(wrapper).toMatchSnapshot(); }); it('should render with plugins', () => { const wrapper = shallow( - , + , ); expect(wrapper).toMatchSnapshot(); }); @@ -81,9 +82,9 @@ describe('components/marketplace/', () => { it('should render with no plugins', () => { const wrapper = shallow( - , ); expect(wrapper).toMatchSnapshot(); @@ -91,9 +92,9 @@ describe('components/marketplace/', () => { it('should render with one plugin', () => { const wrapper = shallow( - , ); expect(wrapper).toMatchSnapshot(); @@ -101,9 +102,9 @@ describe('components/marketplace/', () => { it('should render with multiple plugins', () => { const wrapper = shallow( - , ); expect(wrapper).toMatchSnapshot(); @@ -113,15 +114,19 @@ describe('components/marketplace/', () => { describe('MarketplaceModal', () => { const baseProps: MarketplaceModalProps = { show: true, - plugins: [samplePlugin], - installedPlugins: [], + listing: [samplePlugin], + installedListing: [], pluginStatuses: {}, siteURL: 'http://example.com', firstAdminVisitMarketplaceStatus: false, actions: { closeModal: jest.fn(), - fetchPlugins: jest.fn(), - filterPlugins: jest.fn(), + fetchListing: jest.fn(() => { + return Promise.resolve({}); + }), + filterListing: jest.fn(() => { + return Promise.resolve({}); + }), setFirstAdminVisitMarketplaceStatus: jest.fn(), }, }; @@ -137,10 +142,10 @@ describe('components/marketplace/', () => { const props = { ...baseProps, plugins: [ - ...baseProps.plugins, + ...baseProps.listing, sampleInstalledPlugin, ], - installedPlugins: [ + installedListing: [ sampleInstalledPlugin, ], }; @@ -153,18 +158,18 @@ describe('components/marketplace/', () => { }); test('should fetch plugins when plugin status is changed', () => { - const fetchPlugins = baseProps.actions.fetchPlugins; + const fetchListing = baseProps.actions.fetchListing; const wrapper = shallow(); - expect(fetchPlugins).toBeCalledTimes(1); + expect(fetchListing).toBeCalledTimes(1); wrapper.setProps({...baseProps}); - expect(fetchPlugins).toBeCalledTimes(1); + expect(fetchListing).toBeCalledTimes(1); const status = { id: 'test', } as PluginStatusRedux; wrapper.setProps({...baseProps, pluginStatuses: {test: status}}); - expect(fetchPlugins).toBeCalledTimes(2); + expect(fetchListing).toBeCalledTimes(2); }); test('should render with error banner', () => { diff --git a/components/plugin_marketplace/marketplace_modal.tsx b/components/plugin_marketplace/marketplace_modal.tsx index bb17491f9079..aeb0a63e8dc7 100644 --- a/components/plugin_marketplace/marketplace_modal.tsx +++ b/components/plugin_marketplace/marketplace_modal.tsx @@ -6,7 +6,8 @@ import {FormattedMessage} from 'react-intl'; import debounce from 'lodash/debounce'; import {Tabs, Tab, SelectCallback} from 'react-bootstrap'; -import {MarketplacePlugin, PluginStatusRedux} from 'mattermost-redux/types/plugins'; +import {PluginStatusRedux} from 'mattermost-redux/types/plugins'; +import type {MarketplaceApp, MarketplacePlugin} from 'mattermost-redux/types/marketplace'; import {Dictionary} from 'mattermost-redux/types/utilities'; import FullScreenModal from 'components/widgets/modals/full_screen_modal'; @@ -25,19 +26,19 @@ import './marketplace_modal.scss'; import MarketplaceList from './marketplace_list/marketplace_list'; const MarketplaceTabs = { - ALL_PLUGINS: 'allPlugins', - INSTALLED_PLUGINS: 'installed', + ALL_LISTING: 'allListing', + INSTALLED_LISTING: 'installed', }; const SEARCH_TIMEOUT_MILLISECONDS = 200; -type AllPluginsProps = { - plugins: MarketplacePlugin[]; +type AllListingProps = { + listing: Array; }; -// AllPlugins renders the contents of the all plugins tab. -export const AllPlugins = ({plugins}: AllPluginsProps): JSX.Element => { - if (plugins.length === 0) { +// AllListing renders the contents of the all listing tab. +export const AllListing = ({listing}: AllListingProps): JSX.Element => { + if (listing.length === 0) { return (

@@ -52,17 +53,17 @@ export const AllPlugins = ({plugins}: AllPluginsProps): JSX.Element => { ); } - return ; + return ; }; -type InstalledPluginsProps = { - installedPlugins: MarketplacePlugin[]; +type InstalledListingProps = { + installedItems: Array; changeTab: SelectCallback; }; -// InstalledPlugins renders the contents of the installed plugins tab. -export const InstalledPlugins = ({installedPlugins, changeTab}: InstalledPluginsProps): JSX.Element => { - if (installedPlugins.length === 0) { +// InstalledListing renders the contents of the installed listing tab. +export const InstalledListing = ({installedItems, changeTab}: InstalledListingProps): JSX.Element => { + if (installedItems.length === 0) { return (

@@ -75,7 +76,7 @@ export const InstalledPlugins = ({installedPlugins, changeTab}: InstalledPlugins

@@ -250,23 +250,23 @@ export class MarketplaceModal extends React.PureComponent - {this.state.loading ? : } + {this.state.loading ? : } - diff --git a/components/post_view/embedded_bindings/button_binding/__snapshots__/button_binding.test.tsx.snap b/components/post_view/embedded_bindings/button_binding/__snapshots__/button_binding.test.tsx.snap new file mode 100644 index 000000000000..41e0f971584d --- /dev/null +++ b/components/post_view/embedded_bindings/button_binding/__snapshots__/button_binding.test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/post_view/embedded_bindings/button_binding/ should match snapshot 1`] = ` + +`; diff --git a/components/post_view/embedded_bindings/button_binding/button_binding.test.tsx b/components/post_view/embedded_bindings/button_binding/button_binding.test.tsx new file mode 100644 index 000000000000..cfe3a21cc9f6 --- /dev/null +++ b/components/post_view/embedded_bindings/button_binding/button_binding.test.tsx @@ -0,0 +1,113 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import {Post} from 'mattermost-redux/types/posts'; +import {AppBinding} from 'mattermost-redux/types/apps'; + +import {shallowWithIntl} from 'tests/helpers/intl-test-helper'; + +import ButtonBinding, {ButtonBinding as ButtonBindingUnwrapped} from './button_binding'; + +describe('components/post_view/embedded_bindings/button_binding/', () => { + const post = { + id: 'some_post_id', + channel_id: 'some_channel_id', + root_id: 'some_root_id', + } as Post; + + const binding = { + app_id: 'some_app_id', + label: 'some_label', + location: 'some_location', + call: { + path: 'some_url', + }, + } as AppBinding; + const baseProps = { + post, + userId: 'user_id', + binding, + sendEphemeralPost: jest.fn(), + actions: { + doAppCall: jest.fn().mockResolvedValue({ + data: { + type: 'ok', + markdown: 'Nice job!', + }, + }), + getChannel: jest.fn().mockResolvedValue({ + data: { + id: 'some_channel_id', + team_id: 'some_team_id', + }, + }), + }, + }; + + test('should match snapshot', () => { + const wrapper = shallowWithIntl(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should call doAppCall on click', async () => { + const props = { + ...baseProps, + intl: {} as any, + }; + + const wrapper = shallow(); + await wrapper.instance().handleClick(); + + expect(baseProps.actions.getChannel).toHaveBeenCalledWith('some_channel_id'); + expect(baseProps.actions.doAppCall).toHaveBeenCalledWith({ + context: { + app_id: 'some_app_id', + channel_id: 'some_channel_id', + location: '/in_post/some_location', + post_id: 'some_post_id', + root_id: 'some_root_id', + team_id: 'some_team_id', + }, + expand: { + post: 'all', + }, + path: 'some_url', + query: undefined, + raw_command: undefined, + selected_field: undefined, + values: undefined, + }, 'submit', {}); + + expect(baseProps.sendEphemeralPost).toHaveBeenCalledWith('Nice job!', 'some_channel_id', 'some_root_id'); + }); + + test('should handle error call response', async () => { + const props = { + ...baseProps, + actions: { + doAppCall: jest.fn().mockResolvedValue({ + data: { + type: 'error', + error: 'The error', + }, + }), + getChannel: jest.fn().mockResolvedValue({ + data: { + id: 'some_channel_id', + team_id: 'some_team_id', + }, + }), + }, + sendEphemeralPost: jest.fn(), + intl: {} as any, + }; + + const wrapper = shallow(); + await wrapper.instance().handleClick(); + + expect(props.sendEphemeralPost).toHaveBeenCalledWith('The error', 'some_channel_id', 'some_root_id'); + }); +}); diff --git a/components/post_view/embedded_bindings/button_binding/button_binding.tsx b/components/post_view/embedded_bindings/button_binding/button_binding.tsx new file mode 100644 index 000000000000..233339678ebf --- /dev/null +++ b/components/post_view/embedded_bindings/button_binding/button_binding.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 {injectIntl, IntlShape} from 'react-intl'; + +import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from 'mattermost-redux/types/apps'; +import {ActionResult} from 'mattermost-redux/types/actions'; +import {AppBindingLocations, AppCallResponseTypes, AppCallTypes, AppExpandLevels} from 'mattermost-redux/constants/apps'; +import {Channel} from 'mattermost-redux/types/channels'; +import {Post} from 'mattermost-redux/types/posts'; + +import Markdown from 'components/markdown'; +import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; +import {createCallContext, createCallRequest} from 'utils/apps'; + +type Props = { + intl: IntlShape; + binding: AppBinding; + post: Post; + actions: { + doAppCall: (call: AppCallRequest, type: AppCallType, intl: IntlShape) => Promise; + getChannel: (channelId: string) => Promise; + }; + sendEphemeralPost: (message: string, channelID?: string, rootID?: string) => void; +} + +type State = { + executing: boolean; +} + +export class ButtonBinding extends React.PureComponent { + constructor(props: Props) { + super(props); + this.state = { + executing: false, + }; + } + + handleClick = async () => { + const {binding, post} = this.props; + 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, + post.id, + post.root_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.props.intl); + + this.setState({executing: false}); + const callResp = (res as {data: AppCallResponse}).data; + const ephemeral = (message: string) => this.props.sendEphemeralPost(message, this.props.post.channel_id, this.props.post.root_id); + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.markdown) { + ephemeral(callResp.markdown); + } + break; + case AppCallResponseTypes.ERROR: { + const errorMessage = callResp.error || this.props.intl.formatMessage({id: 'apps.error.unknown', defaultMessage: 'Unknown error happenned'}); + ephemeral(errorMessage); + break; + } + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.FORM: + break; + default: { + const errorMessage = this.props.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; + let customButtonStyle; + + if (!binding.call) { + return null; + } + + return ( + + ); + } +} + +export default injectIntl(ButtonBinding); diff --git a/components/post_view/embedded_bindings/button_binding/index.ts b/components/post_view/embedded_bindings/button_binding/index.ts new file mode 100644 index 000000000000..d3940bc54cc0 --- /dev/null +++ b/components/post_view/embedded_bindings/button_binding/index.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; + +import {ActionFunc, ActionResult, GenericAction} from 'mattermost-redux/types/actions'; +import {AppCallRequest, AppCallType} from 'mattermost-redux/types/apps'; + +import {getChannel} from 'mattermost-redux/actions/channels'; + +import {doAppCall} from 'actions/apps'; +import {sendEphemeralPost} from 'actions/global_actions'; + +import ButtonBinding from './button_binding'; + +type Actions = { + doAppCall: (call: AppCallRequest, type: AppCallType) => Promise; + getChannel: (channelId: string) => Promise; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators, Actions>({ + doAppCall, + getChannel, + }, dispatch), + sendEphemeralPost, + }; +} + +export default connect(null, mapDispatchToProps)(ButtonBinding); diff --git a/components/post_view/embedded_bindings/embedded_binding/__snapshots__/embedded_binding.test.tsx.snap b/components/post_view/embedded_bindings/embedded_binding/__snapshots__/embedded_binding.test.tsx.snap new file mode 100644 index 000000000000..b304fc364e4e --- /dev/null +++ b/components/post_view/embedded_bindings/embedded_binding/__snapshots__/embedded_binding.test.tsx.snap @@ -0,0 +1,213 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/post_view/embedded_bindings/embedded_binding should match snapshot 1`] = ` +
+
+
+

+ +

+
+
+ + + +
+
+
+
+
+
+`; + +exports[`components/post_view/embedded_bindings/embedded_binding should match snapshot when the attachment has a link in the title 1`] = ` +
+
+
+

+ +

+
+
+ + + +
+
+
+
+
+
+`; + +exports[`components/post_view/embedded_bindings/embedded_binding should match snapshot when the attachment has an emoji in the title 1`] = ` +
+
+
+

+ +

+
+
+ + + +
+
+
+
+
+
+`; + +exports[`components/post_view/embedded_bindings/embedded_binding should match snapshot when the attachment hasn't any emojis in the title 1`] = ` +
+
+
+

+ +

+
+
+ + + +
+
+
+
+
+
+`; diff --git a/components/post_view/embedded_bindings/embedded_binding/embedded_binding.test.tsx b/components/post_view/embedded_bindings/embedded_binding/embedded_binding.test.tsx new file mode 100644 index 000000000000..28612d17010d --- /dev/null +++ b/components/post_view/embedded_bindings/embedded_binding/embedded_binding.test.tsx @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import {MessageAttachment as MessageAttachmentType} from 'mattermost-redux/types/message_attachments'; +import {Post} from 'mattermost-redux/types/posts'; + +import {AppBinding} from 'mattermost-redux/types/apps'; + +import EmbeddedBinding from './embedded_binding'; + +describe('components/post_view/embedded_bindings/embedded_binding', () => { + const post = { + id: 'post_id', + channel_id: 'channel_id', + } as Post; + + const embed = { + app_id: 'app_id', + bindings: [] as AppBinding[], + label: 'some text', + description: 'some title', + } as AppBinding; + + const baseProps = { + post, + embed, + currentRelativeTeamUrl: 'dummy_team', + }; + + test('should match snapshot', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot when the attachment has an emoji in the title', () => { + const props = { + ...baseProps, + attachment: { + title: 'Do you like :pizza:?', + } as MessageAttachmentType, + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot when the attachment hasn\'t any emojis in the title', () => { + const props = { + ...baseProps, + attachment: { + title: 'Don\'t you like emojis?', + } as MessageAttachmentType, + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot when the attachment has a link in the title', () => { + const props = { + ...baseProps, + attachment: { + title: 'Do you like https://mattermost.com?', + } as MessageAttachmentType, + }; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/components/post_view/embedded_bindings/embedded_binding/embedded_binding.tsx b/components/post_view/embedded_bindings/embedded_binding/embedded_binding.tsx new file mode 100644 index 000000000000..070e4556d544 --- /dev/null +++ b/components/post_view/embedded_bindings/embedded_binding/embedded_binding.tsx @@ -0,0 +1,167 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +/* eslint-disable react/no-string-refs */ + +import React, {CSSProperties} from 'react'; + +import {AppBinding} from 'mattermost-redux/types/apps'; + +import {Post} from 'mattermost-redux/types/posts'; + +import * as Utils from 'utils/utils'; +import LinkOnlyRenderer from 'utils/markdown/link_only_renderer'; +import {TextFormattingOptions} from 'utils/text_formatting'; + +import Markdown from 'components/markdown'; +import ShowMore from 'components/post_view/show_more'; + +import ButtonBinding from '../button_binding'; +import SelectBinding from '../select_binding'; + +import {fillBindingsInformation} from 'utils/apps'; + +type Props = { + + /** + * The post id + */ + post: Post; + + /** + * The attachment to render + */ + embed: AppBinding; + + /** + * Options specific to text formatting + */ + options?: Partial; + + currentRelativeTeamUrl: string; +} + +export default class EmbeddedBinding extends React.PureComponent { + fillBindings = (binding: AppBinding) => { + const copiedBindings = JSON.parse(JSON.stringify(binding)) as AppBinding; + fillBindingsInformation(copiedBindings); + return copiedBindings.bindings; + } + + renderBindings = () => { + if (!this.props.embed.app_id) { + return null; + } + + if (!this.props.embed.bindings) { + return null; + } + + const bindings = this.fillBindings(this.props.embed); + if (!bindings || !bindings.length) { + return null; + } + + const content = [] as JSX.Element[]; + + bindings.forEach((binding: AppBinding) => { + if (!binding.call || !binding.location) { + return; + } + + if (binding.bindings && binding.bindings.length > 0) { + content.push( + , + ); + } else { + content.push( + , + ); + } + }); + + return ( +
+ {content} +
+ ); + }; + + handleFormattedTextClick = (e: React.MouseEvent) => Utils.handleFormattedTextClick(e, this.props.currentRelativeTeamUrl); + + render() { + const {embed, options} = this.props; + + let title; + if (embed.label) { + title = ( +

+ +

+ ); + } + + let attachmentText; + if (embed.description) { + attachmentText = ( + + + + ); + } + + const bindings = this.renderBindings(); + + return ( +
+
+
+ {title} +
+
+ {attachmentText} + {bindings} +
+
+
+
+
+
+ ); + } +} + +const style = { + footer: {clear: 'both'} as CSSProperties, +}; +/* eslint-enable react/no-string-refs */ diff --git a/components/post_view/embedded_bindings/embedded_binding/index.ts b/components/post_view/embedded_bindings/embedded_binding/index.ts new file mode 100644 index 000000000000..548a330488a0 --- /dev/null +++ b/components/post_view/embedded_bindings/embedded_binding/index.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams'; +import {GlobalState} from 'mattermost-redux/types/store'; + +import EmbeddedBinding from './embedded_binding'; + +function mapStateToProps(state: GlobalState) { + return { + currentRelativeTeamUrl: getCurrentRelativeTeamUrl(state), + }; +} + +export default connect(mapStateToProps)(EmbeddedBinding); diff --git a/components/post_view/embedded_bindings/embedded_bindings.tsx b/components/post_view/embedded_bindings/embedded_bindings.tsx new file mode 100644 index 000000000000..ede8d35d51c9 --- /dev/null +++ b/components/post_view/embedded_bindings/embedded_bindings.tsx @@ -0,0 +1,59 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; + +import {Post} from 'mattermost-redux/types/posts'; + +import {AppBinding} from 'mattermost-redux/types/apps'; + +import {TextFormattingOptions} from 'utils/text_formatting'; + +import EmbeddedBinding from './embedded_binding'; + +type Props = { + + /** + * The post id + */ + post: Post; + + /** + * Array of attachments to render + */ + embeds: AppBinding[]; // Type App Embed Wrapper + + /** + * Options specific to text formatting + */ + options?: Partial; + +} + +export default class EmbeddedBindings extends React.PureComponent { + static defaultProps = { + imagesMetadata: {}, + } + + render() { + const content = [] as JSX.Element[]; + this.props.embeds.forEach((embed, i) => { + content.push( + , + ); + }); + + return ( +
+ {content} +
+ ); + } +} diff --git a/components/post_view/embedded_bindings/select_binding/index.ts b/components/post_view/embedded_bindings/select_binding/index.ts new file mode 100644 index 000000000000..7171ea3e9c78 --- /dev/null +++ b/components/post_view/embedded_bindings/select_binding/index.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; + +import {ActionFunc, ActionResult, GenericAction} from 'mattermost-redux/types/actions'; + +import {AppCallRequest, AppCallType} from 'mattermost-redux/types/apps'; + +import {getChannel} from 'mattermost-redux/actions/channels'; + +import {doAppCall} from 'actions/apps'; +import {sendEphemeralPost} from 'actions/global_actions'; + +import SelectBinding from './select_binding'; + +type Actions = { + doAppCall: (call: AppCallRequest, type: AppCallType) => Promise; + getChannel: (channelId: string) => Promise; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators, Actions>({ + doAppCall, + getChannel, + }, dispatch), + sendEphemeralPost, + }; +} + +export default connect(null, mapDispatchToProps)(SelectBinding); diff --git a/components/post_view/embedded_bindings/select_binding/select_binding.test.tsx b/components/post_view/embedded_bindings/select_binding/select_binding.test.tsx new file mode 100644 index 000000000000..7651aafa3f5b --- /dev/null +++ b/components/post_view/embedded_bindings/select_binding/select_binding.test.tsx @@ -0,0 +1,157 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import {AppBinding} from 'mattermost-redux/types/apps'; +import {Post} from 'mattermost-redux/types/posts'; + +import {shallowWithIntl} from 'tests/helpers/intl-test-helper'; + +import SelectBinding, {SelectBinding as SelectBindingUnwrapped} from './select_binding'; + +describe('components/post_view/embedded_bindings/select_binding', () => { + const post = { + id: 'some_post_id', + channel_id: 'some_channel_id', + root_id: 'some_root_id', + } as Post; + + const binding = { + app_id: 'some_app_id', + location: '/some_location', + call: { + path: 'some_url', + }, + bindings: [ + { + app_id: 'some_app_id', + label: 'Option 1', + location: 'option1', + call: { + path: 'some_url_1', + }, + }, + { + app_id: 'some_app_id', + label: 'Option 2', + location: 'option2', + call: { + path: 'some_url_2', + }, + }, + ] as AppBinding[], + } as AppBinding; + + const baseProps = { + post, + userId: 'user_id', + binding, + actions: { + doAppCall: jest.fn().mockResolvedValue({ + data: { + type: 'ok', + markdown: 'Nice job!', + }, + }), + getChannel: jest.fn().mockResolvedValue({ + data: { + id: 'some_channel_id', + team_id: 'some_team_id', + }, + }), + }, + sendEphemeralPost: jest.fn(), + }; + + test('should start with nothing selected', () => { + const wrapper = shallowWithIntl(); + + expect(wrapper.state()).toMatchObject({}); + }); + + describe('handleSelected', () => { + test('should should call doAppCall', async () => { + const props = { + ...baseProps, + actions: { + doAppCall: jest.fn().mockResolvedValue({ + data: { + type: 'ok', + markdown: 'Nice job!', + }, + }), + getChannel: jest.fn().mockResolvedValue({ + data: { + id: 'some_channel_id', + team_id: 'some_team_id', + }, + }), + }, + sendEphemeralPost: jest.fn(), + intl: {} as any, + }; + + const wrapper = shallow(); + + await wrapper.instance().handleSelected({ + text: 'Option 1', + value: 'option1', + }); + + expect(props.actions.getChannel).toHaveBeenCalledWith('some_channel_id'); + expect(props.actions.doAppCall).toHaveBeenCalledWith({ + context: { + app_id: 'some_app_id', + channel_id: 'some_channel_id', + location: '/in_post/option1', + post_id: 'some_post_id', + root_id: 'some_root_id', + team_id: 'some_team_id', + }, + expand: { + post: 'all', + }, + path: 'some_url_1', + query: undefined, + raw_command: undefined, + selected_field: undefined, + values: undefined, + }, 'submit', {}); + + expect(props.sendEphemeralPost).toHaveBeenCalledWith('Nice job!', 'some_channel_id', 'some_root_id'); + }); + }); + + test('should handle error call response', async () => { + const props = { + ...baseProps, + actions: { + doAppCall: jest.fn().mockResolvedValue({ + data: { + type: 'error', + error: 'The error', + }, + }), + getChannel: jest.fn().mockResolvedValue({ + data: { + id: 'some_channel_id', + team_id: 'some_team_id', + }, + }), + }, + sendEphemeralPost: jest.fn(), + intl: {} as any, + }; + + const wrapper = shallow(); + + await wrapper.instance().handleSelected({ + text: 'Option 1', + value: 'option1', + }); + + expect(props.sendEphemeralPost).toHaveBeenCalledWith('The error', 'some_channel_id', 'some_root_id'); + }); +}); diff --git a/components/post_view/embedded_bindings/select_binding/select_binding.tsx b/components/post_view/embedded_bindings/select_binding/select_binding.tsx new file mode 100644 index 000000000000..40d8151484c8 --- /dev/null +++ b/components/post_view/embedded_bindings/select_binding/select_binding.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 {injectIntl, IntlShape} from 'react-intl'; + +import {ActionResult} from 'mattermost-redux/types/actions'; + +import {Post} from 'mattermost-redux/types/posts'; + +import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from 'mattermost-redux/types/apps'; + +import {AppBindingLocations, AppCallResponseTypes, AppCallTypes, AppExpandLevels} from 'mattermost-redux/constants/apps'; + +import {Channel} from 'mattermost-redux/types/channels'; + +import MenuActionProvider from 'components/suggestion/menu_action_provider'; +import AutocompleteSelector from 'components/autocomplete_selector'; +import PostContext from 'components/post_view/post_context'; +import {createCallContext, createCallRequest} from 'utils/apps'; + +type Option = { + text: string; + value: string; +}; + +type Props = { + intl: IntlShape; + post: Post; + binding: AppBinding; + actions: { + doAppCall: (call: AppCallRequest, type: AppCallType, intl: IntlShape) => Promise; + getChannel: (channelId: string) => Promise; + }; + sendEphemeralPost: (message: string, channelID?: string, rootID?: string) => void; +}; + +type State = { + selected?: Option; +}; + +export class SelectBinding extends React.PureComponent { + private providers: MenuActionProvider[]; + + constructor(props: Props) { + super(props); + + const binding = props.binding; + this.providers = []; + if (binding.bindings) { + const options = binding.bindings.map((b) => { + return {text: b.label, value: b.location}; + }); + this.providers = [new MenuActionProvider(options)]; + } + + this.state = {}; + } + + handleSelected = async (selected: Option) => { + if (!selected) { + return; + } + + 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 {post} = this.props; + + 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, + post.id, + post.root_id, + ); + const call = createCallRequest( + binding.call, + context, + {post: AppExpandLevels.EXPAND_ALL}, + ); + + const res = await this.props.actions.doAppCall(call, AppCallTypes.SUBMIT, this.props.intl); + const callResp = (res as {data: AppCallResponse}).data; + const ephemeral = (message: string) => this.props.sendEphemeralPost(message, this.props.post.channel_id, this.props.post.root_id); + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.markdown) { + ephemeral(callResp.markdown); + } + break; + case AppCallResponseTypes.ERROR: { + const errorMessage = callResp.error || this.props.intl.formatMessage({id: 'apps.error.unknown', defaultMessage: 'Unknown error happenned'}); + ephemeral(errorMessage); + break; + } + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.FORM: + break; + default: { + const errorMessage = this.props.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; + + return ( + + {({handlePopupOpened}) => ( + + )} + + ); + } +} + +export default injectIntl(SelectBinding); diff --git a/components/post_view/post_body_additional_content/index.ts b/components/post_view/post_body_additional_content/index.ts index 7322a51736f3..4d7a7fcf4d21 100644 --- a/components/post_view/post_body_additional_content/index.ts +++ b/components/post_view/post_body_additional_content/index.ts @@ -5,6 +5,7 @@ import {connect} from 'react-redux'; import {bindActionCreators, Dispatch} from 'redux'; import {GenericAction} from 'mattermost-redux/types/actions'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {toggleEmbedVisibility} from 'actions/post_actions'; import {isEmbedVisible} from 'selectors/posts'; @@ -19,6 +20,7 @@ function mapStateToProps(state: GlobalState, ownProps: Props) { return { isEmbedVisible: isEmbedVisible(state, ownProps.post.id), pluginPostWillRenderEmbedComponents: state.plugins.components.PostWillRenderEmbedComponent as unknown as PostWillRenderEmbedPluginComponent[], + appsEnabled: appsEnabled(state), }; } diff --git a/components/post_view/post_body_additional_content/post_body_additional_content.test.tsx b/components/post_view/post_body_additional_content/post_body_additional_content.test.tsx index d905e68f4324..d6c94101e040 100644 --- a/components/post_view/post_body_additional_content/post_body_additional_content.test.tsx +++ b/components/post_view/post_body_additional_content/post_body_additional_content.test.tsx @@ -44,6 +44,7 @@ describe('PostBodyAdditionalContent', () => { actions: { toggleEmbedVisibility: jest.fn(), }, + appsEnabled: false, }; describe('with an image preview', () => { diff --git a/components/post_view/post_body_additional_content/post_body_additional_content.tsx b/components/post_view/post_body_additional_content/post_body_additional_content.tsx index 0ae99207b86b..7d102ead9d9b 100644 --- a/components/post_view/post_body_additional_content/post_body_additional_content.tsx +++ b/components/post_view/post_body_additional_content/post_body_additional_content.tsx @@ -6,6 +6,7 @@ import React from 'react'; import {Post, PostEmbed} from 'mattermost-redux/types/posts'; import {getEmbedFromMetadata} from 'mattermost-redux/utils/post_utils'; +import {AppBinding} from 'mattermost-redux/types/apps'; import MessageAttachmentList from 'components/post_view/message_attachments/message_attachment_list'; import PostAttachmentOpenGraph from 'components/post_view/post_attachment_opengraph'; @@ -13,6 +14,7 @@ import PostImage from 'components/post_view/post_image'; import YoutubeVideo from 'components/youtube_video'; import {PostWillRenderEmbedPluginComponent} from 'types/store/plugins'; +import EmbeddedBindings from '../embedded_bindings/embedded_bindings'; import {TextFormattingOptions} from 'utils/text_formatting'; export type Props = { @@ -21,6 +23,7 @@ export type Props = { children?: JSX.Element; isEmbedVisible?: boolean; options?: Partial; + appsEnabled: boolean; actions: { toggleEmbedVisibility: (id: string) => void; }; @@ -131,6 +134,21 @@ export default class PostBodyAdditionalContent extends React.PureComponent + {this.props.children} + + + ); + } + } + if (embed) { const toggleable = this.isEmbedToggleable(embed); const prependToggle = (/^\s*https?:\/\/.*$/).test(this.props.post.message); @@ -148,3 +166,21 @@ export default class PostBodyAdditionalContent extends React.PureComponent) { + if (!props) { + return false; + } + + if (!props.app_bindings) { + return false; + } + + const embeds = props.app_bindings as AppBinding[]; + + if (!embeds.length) { + return false; + } + + return true; +} diff --git a/components/post_view/post_info/__snapshots__/post_info.test.tsx.snap b/components/post_view/post_info/__snapshots__/post_info.test.tsx.snap index e33b084a053a..be40a02c2a0f 100644 --- a/components/post_view/post_info/__snapshots__/post_info.test.tsx.snap +++ b/components/post_view/post_info/__snapshots__/post_info.test.tsx.snap @@ -164,7 +164,7 @@ exports[`components/post_view/PostInfo should match snapshot, hover 1`] = ` className="col post-menu" data-testid="post-menu-e584uzbwwpny9kengqayx5ayzw" > - - - - - - - - - { + 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 match.'}, + submit: {expectError: '`{command}`: no match.'}, + }, + { + title: 'no space after the top command', + command: '/jira', + autocomplete: {expectError: '`{command}`: no match.'}, + 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/components/suggestion/command_provider/app_command_parser/app_command_parser.ts b/components/suggestion/command_provider/app_command_parser/app_command_parser.ts new file mode 100644 index 000000000000..e7c3e7b80141 --- /dev/null +++ b/components/suggestion/command_provider/app_command_parser/app_command_parser.ts @@ -0,0 +1,1261 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable max-lines */ + +import { + AppCallRequest, + AppBinding, + AppField, + AppSelectOption, + AppCallResponse, + AppContext, + AppForm, + AppCallValues, + AutocompleteSuggestion, + AutocompleteStaticSelect, + Channel, + DispatchFunc, + GlobalState, + + AppBindingLocations, + AppCallResponseTypes, + AppCallTypes, + AppFieldTypes, + makeAppBindingsSelector, + getChannel, + getCurrentTeamId, + doAppCall, + getStore, + EXECUTE_CURRENT_COMMAND_ITEM_ID, + getExecuteSuggestion, + displayError, + createCallRequest, + selectUserByUsername, + getUserByUsername, + getChannelByNameAndTeamName, + getCurrentTeam, + selectChannelByName, +} from './app_command_parser_dependencies'; + +export interface Store { + dispatch: DispatchFunc; + getState: () => GlobalState; +} + +export enum ParseState { + Start = 'Start', + Command = 'Command', + EndCommand = 'EndCommand', + CommandSeparator = 'CommandSeparator', + StartParameter = 'StartParameter', + ParameterSeparator = 'ParameterSeparator', + Flag1 = 'Flag1', + Flag = 'Flag', + FlagValueSeparator = 'FlagValueSeparator', + StartValue = 'StartValue', + NonspaceValue = 'NonspaceValue', + QuotedValue = 'QuotedValue', + TickValue = 'TickValue', + EndValue = 'EndValue', + EndQuotedValue = 'EndQuotedValue', + EndTickedValue = 'EndTickedValue', + Error = 'Error', +} + +interface FormsCache { + getForm: (location: string, binding: AppBinding) => Promise; +} + +interface Intl { + formatMessage(config: {id: string; defaultMessage: string}, values?: {[name: string]: any}): string; +} + +const getCommandBindings = makeAppBindingsSelector(AppBindingLocations.COMMAND); + +export class ParsedCommand { + state = 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: Intl; + + constructor(command: string, formsCache: FormsCache, intl: any) { + this.command = command; + this.formsCache = formsCache || []; + this.intl = intl; + } + + private asError = (message: string): ParsedCommand => { + this.state = ParseState.Error; + this.error = message; + return this; + }; + + public 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. + public 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.toLowerCase() === 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 match.', + }, { + 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. + public 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?.toLowerCase() === 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 Expected.', + })); + } + + // 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: Intl; + + forms: {[location: string]: AppForm} = {}; + + constructor(store: Store|null, intl: Intl, channelID: string, rootPostID = '') { + this.store = store || getStore(); + 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.parser.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.label; + if (!base) { + continue; + } + + if (base[0] !== '/') { + base = '/' + base; + } + + if (base.startsWith(command)) { + result.push({ + Complete: binding.label, + Suggestion: base, + 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 + private 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); + } + + private expandOptions = async (parsed: ParsedCommand, values: AppCallValues) => { + if (!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], + })); + return; + } + 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 + private 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 + private getCommandBindings = (): AppBinding[] => { + const bindings = getCommandBindings(this.store.getState()); + return bindings; + } + + // getChannel gets the channel in which the user is typing the command + private getChannel = (): Channel | null => { + const state = this.store.getState(); + return getChannel(state, this.channelID); + } + + public setChannelContext = (channelID: string, rootPostID?: string) => { + this.channelID = channelID; + this.rootPostID = rootPostID; + } + + // isAppCommand determines if subcommand/form suggestions need to be returned. + // When this returns true, the caller knows that the parser should handle all suggestions for the current command string. + // When it returns false, the caller should call getSuggestionsBase() to check if there are any base commands that match the command string. + public isAppCommand = (pretext: string): boolean => { + const command = pretext.toLowerCase(); + for (const binding of this.getCommandBindings()) { + let base = binding.label; + 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 + private 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) + private 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)) as {data: AppCallResponse}; + const callResponse = res.data; + switch (callResponse.type) { + case AppCallResponseTypes.FORM: + break; + case AppCallResponseTypes.ERROR: + this.displayError(callResponse.error || this.intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error.', + })); + return undefined; + 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 callResponse.form; + } + + public 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 + private displayError = (err: any): void => { + let errStr = err as string; + if (err.message) { + errStr = err.message; + } + displayError(errStr); + } + + // getSuggestionsForSubCommands returns suggestions for a subcommand's name + private 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 + private 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 + private 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 + private 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.toLowerCase().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 [{ + Complete: '', + Suggestion: this.intl.formatMessage({ + id: 'apps.suggestion.no_suggestions', + defaultMessage: 'Could not find any suggestions.', + }), + Description: '', + Hint: '', + IconData: '', + }]; + } + + // getSuggestionsForField gets suggestions for a positional or flag field value + private 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 + private 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 + private 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.responses.unexpected_error', + defaultMessage: 'Received an 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)) as {data: AppCallResponse}; + const callResponse = res.data; + + switch (callResponse.type) { + case AppCallResponseTypes.OK: + break; + case AppCallResponseTypes.ERROR: + return this.makeSuggestionError(callResponse.error || this.intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error.', + })); + 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: '', + Description: this.intl.formatMessage({ + id: 'apps.suggestion.no_dynamic', + defaultMessage: 'Received no data for dynamic suggestions.', + }), + IconData: '', + }]; + } + + 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 || '', + }); + }); + } + + private makeSuggestionError = (message: string): AutocompleteSuggestion[] => { + const errMsg = this.intl.formatMessage({ + id: 'apps.error', + defaultMessage: 'Error: {error}', + }, { + error: message, + }); + return [{ + Complete: '', + Suggestion: '', + Description: errMsg, + Hint: '', + IconData: '', + }]; + } + + // getUserSuggestions returns a suggestion with `@` if the user has not started typing + private 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 + private 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 + private 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/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts b/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts new file mode 100644 index 000000000000..5d67dff97a1e --- /dev/null +++ b/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export type { + AppCallRequest, + AppCallValues, + AppBinding, + AppField, + AppSelectOption, + AppCallResponse, + AppContext, + AppForm, + AutocompleteElement, + AutocompleteDynamicSelect, + AutocompleteStaticSelect, + AutocompleteUserSelect, + AutocompleteChannelSelect, +} from 'mattermost-redux/types/apps'; + +import type { + AutocompleteSuggestion, +} from 'mattermost-redux/types/integrations'; +export type {AutocompleteSuggestion}; + +export type { + Channel, +} from 'mattermost-redux/types/channels'; + +export { + GlobalState, +} from 'types/store'; + +export type { + DispatchFunc, +} from 'mattermost-redux/types/actions'; + +export { + AppBindingLocations, + AppCallTypes, + AppFieldTypes, + AppCallResponseTypes, +} from 'mattermost-redux/constants/apps'; + +export {makeAppBindingsSelector} from 'mattermost-redux/selectors/entities/apps'; + +export {getPost} from 'mattermost-redux/selectors/entities/posts'; +export {getChannel, getCurrentChannel, getChannelByName as selectChannelByName} from 'mattermost-redux/selectors/entities/channels'; +export {getCurrentTeamId, getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; +export {getUserByUsername as selectUserByUsername} from 'mattermost-redux/selectors/entities/users'; + +export {getUserByUsername} from 'mattermost-redux/actions/users'; +export {getChannelByNameAndTeamName} from 'mattermost-redux/actions/channels'; + +export {doAppCall} from 'actions/apps'; +import {sendEphemeralPost} from 'actions/global_actions'; + +export {createCallRequest} from 'utils/apps'; +import {isMac, localizeAndFormatMessage} from 'utils/utils'; + +import Store from 'stores/redux_store'; +export const getStore = () => Store; + +export const EXECUTE_CURRENT_COMMAND_ITEM_ID = '_execute_current_command'; + +import type {ParsedCommand} from './app_command_parser'; + +export const getExecuteSuggestion = (parsed: ParsedCommand): AutocompleteSuggestion | null => { + let key = 'Ctrl'; + if (isMac()) { + key = '⌘'; + } + + return { + Complete: parsed.command.substring(1) + EXECUTE_CURRENT_COMMAND_ITEM_ID, + Suggestion: 'Execute Current Command', + Hint: '', + Description: 'Select this option or use ' + key + '+Enter to execute the current command.', + IconData: EXECUTE_CURRENT_COMMAND_ITEM_ID, + }; +}; + +export const displayError = (err: string) => { + sendEphemeralPost(err); +}; + +// Shim of mobile-version intl +export const intlShim = { + formatMessage: (config: {id: string; defaultMessage: string}, values?: {[name: string]: any}) => { + return localizeAndFormatMessage(config.id, config.defaultMessage, values); + }, +}; diff --git a/components/suggestion/command_provider/app_command_parser/tests/app_command_parser_test_data.ts b/components/suggestion/command_provider/app_command_parser/tests/app_command_parser_test_data.ts new file mode 100644 index 000000000000..6b977fb59ecb --- /dev/null +++ b/components/suggestion/command_provider/app_command_parser/tests/app_command_parser_test_data.ts @@ -0,0 +1,231 @@ +// 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, + FeatureFlagAppsEnabled: 'true', + }, + }, + }, +}; + +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/components/suggestion/command_provider/app_command_parser/tests/app_command_parser_test_dependencies.ts b/components/suggestion/command_provider/app_command_parser/tests/app_command_parser_test_dependencies.ts new file mode 100644 index 000000000000..dec5388145ef --- /dev/null +++ b/components/suggestion/command_provider/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 'mattermost-redux/client'; + +export type {AppBinding, AppForm} from 'mattermost-redux/types/apps'; +export {AppFieldTypes} from 'mattermost-redux/constants/apps'; + +export const checkForExecuteSuggestion = true; diff --git a/components/suggestion/command_provider/command_provider.test.tsx b/components/suggestion/command_provider/command_provider.test.tsx index feeedd386ae2..df7a04b152b2 100644 --- a/components/suggestion/command_provider/command_provider.test.tsx +++ b/components/suggestion/command_provider/command_provider.test.tsx @@ -6,16 +6,21 @@ import {shallow} from 'enzyme'; import {Client4} from 'mattermost-redux/client'; +import {AutocompleteSuggestion} from 'mattermost-redux/types/integrations'; + import CommandProvider, {CommandSuggestion, Results} from './command_provider'; describe('CommandSuggestion', () => { + const suggestion: AutocompleteSuggestion = { + Suggestion: '/invite', + Complete: '/invite', + Hint: '@[username] ~[channel]', + Description: 'Invite a user to a channel', + IconData: '', + }; + const baseProps = { - item: { - suggestion: '/invite', - hint: '@[username] ~[channel]', - description: 'Invite a user to a channel', - iconData: '', - }, + item: suggestion, isSelection: true, term: '/', matchedPretext: '', @@ -34,7 +39,42 @@ describe('CommandSuggestion', () => { describe('CommandProvider', () => { describe('handlePretextChanged', () => { - test('should fetch results from the server', async () => { + test('should fetch command autocomplete results from the server', async () => { + const f = Client4.getCommandAutocompleteSuggestionsList; + + const mockFunc = jest.fn().mockResolvedValue([{ + Suggestion: 'issue', + Complete: 'jira issue', + Hint: 'hint', + IconData: 'icon_data', + Description: 'description', + }]); + Client4.getCommandAutocompleteSuggestionsList = mockFunc; + + const provider = new CommandProvider({isInRHS: false}); + + const callback = jest.fn(); + provider.handlePretextChanged('/jira issue', callback); + await mockFunc(); + + const expected: Results = { + matchedPretext: '/jira issue', + terms: ['/jira issue'], + items: [{ + Complete: '/jira issue', + Suggestion: '/issue', + Hint: 'hint', + IconData: 'icon_data', + Description: 'description', + }], + component: CommandSuggestion, + }; + expect(callback).toHaveBeenCalledWith(expected); + + Client4.getCommandAutocompleteSuggestionsList = f; + }); + + test('should use the app command parser', async () => { const f = Client4.getCommandAutocompleteSuggestionsList; const mockFunc = jest.fn().mockResolvedValue([{ @@ -56,11 +96,11 @@ describe('CommandProvider', () => { matchedPretext: '/jira issue', terms: ['/jira issue'], items: [{ - complete: '/jira issue', - suggestion: '/issue', - hint: 'hint', - iconData: 'icon_data', - description: 'description', + Complete: '/jira issue', + Suggestion: '/issue', + Hint: 'hint', + IconData: 'icon_data', + Description: 'description', }], component: CommandSuggestion, }; diff --git a/components/suggestion/command_provider/command_provider.tsx b/components/suggestion/command_provider/command_provider.tsx index 8d28809adeb3..68f24a844481 100644 --- a/components/suggestion/command_provider/command_provider.tsx +++ b/components/suggestion/command_provider/command_provider.tsx @@ -2,18 +2,18 @@ // See LICENSE.txt for license information. import React from 'react'; + import {Store} from 'redux'; import {Client4} from 'mattermost-redux/client'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; -import {getChannel, getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; -import {CommandAutocompleteSuggestion} from 'mattermost-redux/types/integrations'; +import {getChannel, getCurrentChannel, getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; +import {AutocompleteSuggestion} from 'mattermost-redux/types/integrations'; import {Post} from 'mattermost-redux/types/posts'; import globalStore from 'stores/redux_store'; -import {GlobalState} from 'types/store'; - import {getSelectedPost} from 'selectors/rhs'; import * as UserAgent from 'utils/user_agent'; @@ -23,37 +23,34 @@ import {Constants} from 'utils/constants'; import Suggestion from '../suggestion'; import Provider from '../provider'; -const EXECUTE_CURRENT_COMMAND_ITEM_ID = Constants.Integrations.EXECUTE_CURRENT_COMMAND_ITEM_ID; +import {GlobalState} from 'types/store'; -export type CommandSuggestionItem = { - complete: string; - suggestion: string; - hint: string; - description: string; - iconData: string; -}; +import {AppCommandParser} from './app_command_parser/app_command_parser'; +import {intlShim} from './app_command_parser/app_command_parser_dependencies'; + +const EXECUTE_CURRENT_COMMAND_ITEM_ID = Constants.Integrations.EXECUTE_CURRENT_COMMAND_ITEM_ID; export class CommandSuggestion extends Suggestion { render() { const {isSelection} = this.props; - const item = this.props.item as CommandSuggestionItem; + const item = this.props.item as AutocompleteSuggestion; let className = 'slash-command'; if (isSelection) { className += ' suggestion--selected'; } let symbolSpan = {'/'}; - if (item.iconData === EXECUTE_CURRENT_COMMAND_ITEM_ID) { + if (item.IconData === EXECUTE_CURRENT_COMMAND_ITEM_ID) { symbolSpan = {'↵'}; } let icon =
{symbolSpan}
; - if (item.iconData && item.iconData !== EXECUTE_CURRENT_COMMAND_ITEM_ID) { + if (item.IconData && item.IconData !== EXECUTE_CURRENT_COMMAND_ITEM_ID) { icon = (
- +
); } @@ -67,10 +64,10 @@ export class CommandSuggestion extends Suggestion { {icon}
- {item.suggestion.substring(1) + ' ' + item.hint} + {item.Suggestion.substring(1) + ' ' + item.Hint}
- {item.description} + {item.Description}
@@ -85,9 +82,9 @@ type Props = { export type Results = { matchedPretext: string; terms: string[]; - items: CommandSuggestionItem[]; + items: AutocompleteSuggestion[]; component: React.ElementType; -}; +} type ResultsCallback = (results: Results) => void; @@ -95,12 +92,24 @@ export default class CommandProvider extends Provider { private isInRHS: boolean; private store: Store; private triggerCharacter: string; + private appCommandParser: AppCommandParser; constructor(props: Props) { super(); this.store = globalStore; this.isInRHS = props.isInRHS; + let rootId; + let channelId = getCurrentChannelId(this.store.getState()); + if (this.isInRHS) { + const selectedPost = getSelectedPost(this.store.getState()) as Post; + if (selectedPost) { + channelId = selectedPost?.channel_id; + rootId = selectedPost?.root_id ? selectedPost.root_id : selectedPost.id; + } + } + + this.appCommandParser = new AppCommandParser(this.store as any, intlShim, channelId, rootId); this.triggerCharacter = '/'; } @@ -109,6 +118,25 @@ export default class CommandProvider extends Provider { return false; } + if (appsEnabled(this.store.getState()) && this.appCommandParser.isAppCommand(pretext)) { + this.appCommandParser.getSuggestions(pretext).then((suggestions) => { + const matches = suggestions.map((suggestion) => ({ + ...suggestion, + Complete: '/' + suggestion.Complete, + Suggestion: '/' + suggestion.Suggestion, + })); + + const terms = matches.map((suggestion) => suggestion.Complete); + resultCallback({ + matchedPretext: pretext, + terms, + items: matches, + component: CommandSuggestion, + }); + }); + return true; + } + if (UserAgent.isMobile()) { this.handleMobile(pretext, resultCallback); } else { @@ -125,7 +153,11 @@ export default class CommandProvider extends Provider { const command = pretext.toLowerCase(); Client4.getCommandsList(getCurrentTeamId(this.store.getState())).then( (data) => { - let matches: CommandSuggestionItem[] = []; + let matches: AutocompleteSuggestion[] = []; + if (appsEnabled(this.store.getState())) { + const appCommandSuggestions = this.appCommandParser.getSuggestionsBase(pretext); + matches = matches.concat(appCommandSuggestions); + } data.forEach((cmd) => { if (!cmd.auto_complete) { @@ -140,20 +172,20 @@ export default class CommandProvider extends Provider { hint = cmd.auto_complete_hint; } matches.push({ - suggestion: s, - complete: '', - hint, - description: cmd.auto_complete_desc, - iconData: '', + Suggestion: s, + Complete: '', + Hint: hint, + Description: cmd.auto_complete_desc, + IconData: '', }); } } }); - matches = matches.sort((a, b) => a.suggestion.localeCompare(b.suggestion)); + matches = matches.sort((a, b) => a.Suggestion.localeCompare(b.Suggestion)); // pull out the suggested commands from the returned data - const terms = matches.map((suggestion) => suggestion.suggestion); + const terms = matches.map((suggestion) => suggestion.Suggestion); resultCallback({ matchedPretext: command, @@ -183,22 +215,31 @@ export default class CommandProvider extends Provider { }; Client4.getCommandAutocompleteSuggestionsList(command, teamId, args).then( - ((data: CommandAutocompleteSuggestion[]) => { - const matches: CommandSuggestionItem[] = []; + ((data: AutocompleteSuggestion[]) => { + let matches: AutocompleteSuggestion[] = []; let cmd = 'Ctrl'; if (Utils.isMac()) { cmd = '⌘'; } + if (appsEnabled(this.store.getState()) && this.appCommandParser) { + const appCommandSuggestions = this.appCommandParser.getSuggestionsBase(pretext).map((suggestion) => ({ + ...suggestion, + Complete: '/' + suggestion.Complete, + Suggestion: suggestion.Suggestion, + })); + matches = matches.concat(appCommandSuggestions); + } + data.forEach((s) => { if (!this.contains(matches, this.triggerCharacter + s.Complete)) { matches.push({ - complete: this.triggerCharacter + s.Complete, - suggestion: this.triggerCharacter + s.Suggestion, - hint: s.Hint, - description: s.Description, - iconData: s.IconData, + Complete: this.triggerCharacter + s.Complete, + Suggestion: this.triggerCharacter + s.Suggestion, + Hint: s.Hint, + Description: s.Description, + IconData: s.IconData, }); } }); @@ -206,9 +247,9 @@ export default class CommandProvider extends Provider { // sort only if we are looking at base commands if (!pretext.includes(' ')) { matches.sort((a, b) => { - if (a.suggestion.toLowerCase() > b.suggestion.toLowerCase()) { + if (a.Suggestion.toLowerCase() > b.Suggestion.toLowerCase()) { return 1; - } else if (a.suggestion.toLowerCase() < b.suggestion.toLowerCase()) { + } else if (a.Suggestion.toLowerCase() < b.Suggestion.toLowerCase()) { return -1; } return 0; @@ -217,16 +258,16 @@ export default class CommandProvider extends Provider { if (this.shouldAddExecuteItem(data, pretext)) { matches.unshift({ - complete: pretext + EXECUTE_CURRENT_COMMAND_ITEM_ID, - suggestion: '/Execute Current Command', - hint: '', - description: 'Select this option or use ' + cmd + '+Enter to execute the current command.', - iconData: EXECUTE_CURRENT_COMMAND_ITEM_ID, + Complete: pretext + EXECUTE_CURRENT_COMMAND_ITEM_ID, + Suggestion: '/Execute Current Command', + Hint: '', + Description: 'Select this option or use ' + cmd + '+Enter to execute the current command.', + IconData: EXECUTE_CURRENT_COMMAND_ITEM_ID, }); } // pull out the suggested commands from the returned data - const terms = matches.map((suggestion) => suggestion.complete); + const terms = matches.map((suggestion) => suggestion.Complete); resultCallback({ matchedPretext: command, @@ -238,7 +279,7 @@ export default class CommandProvider extends Provider { ); } - shouldAddExecuteItem(data: CommandAutocompleteSuggestion[], pretext: string) { + shouldAddExecuteItem(data: AutocompleteSuggestion[], pretext: string) { if (data.length === 0) { return false; } @@ -250,7 +291,7 @@ export default class CommandProvider extends Provider { return data.findIndex((item) => item.Suggestion === '') !== -1; } - contains(matches: CommandSuggestionItem[], complete: string) { - return matches.findIndex((match) => match.complete === complete) !== -1; + contains(matches: AutocompleteSuggestion[], complete: string) { + return matches.findIndex((match) => match.Complete === complete) !== -1; } } diff --git a/components/suggestion/emoticon_provider.jsx b/components/suggestion/emoticon_provider.jsx index acb50ad80653..a88b30d19db0 100644 --- a/components/suggestion/emoticon_provider.jsx +++ b/components/suggestion/emoticon_provider.jsx @@ -60,7 +60,7 @@ export default class EmoticonProvider extends Provider { } handlePretextChanged(pretext, resultsCallback) { // Look for the potential emoticons at the start of the text, after whitespace, and at the start of emoji reaction commands - const captured = (/(^|\s|^\+|^-)(:([^:\s]*))$/g).exec(pretext); + const captured = (/(^|\s|^\+|^-)(:([^:\s]*))$/g).exec(pretext.toLowerCase()); if (!captured) { return false; } diff --git a/components/suggestion/suggestion_box.jsx b/components/suggestion/suggestion_box.jsx index 89b630961863..84ecab0b2993 100644 --- a/components/suggestion/suggestion_box.jsx +++ b/components/suggestion/suggestion_box.jsx @@ -343,7 +343,7 @@ export default class SuggestionBox extends React.PureComponent { handleChange = (e) => { const textbox = this.getTextbox(); - const pretext = textbox.value.substring(0, textbox.selectionEnd).toLowerCase(); + const pretext = textbox.value.substring(0, textbox.selectionEnd); if (!this.composing && this.pretext !== pretext) { this.handlePretextChanged(pretext); diff --git a/components/widgets/settings/bool_setting.tsx b/components/widgets/settings/bool_setting.tsx index 6f54c6d24936..f03c984aa827 100644 --- a/components/widgets/settings/bool_setting.tsx +++ b/components/widgets/settings/bool_setting.tsx @@ -12,6 +12,7 @@ type Props = { helpText?: React.ReactNode; placeholder: string; value: boolean; + disabled?: boolean; inputClassName: string; onChange(name: string, value: any): void; // value is any since onChange is a common func for inputs and checkboxes autoFocus?: boolean; @@ -40,6 +41,7 @@ export default class BoolSetting extends React.PureComponent {