diff --git a/actions/apps.ts b/actions/apps.ts index f933ca800eea..3c35ed31a2b6 100644 --- a/actions/apps.ts +++ b/actions/apps.ts @@ -19,51 +19,61 @@ const ephemeral = (text: string, call?: AppCall) => sendEphemeralPost(text, (cal export function doAppCall(call: AppCall): ActionFunc { return async (dispatch: DispatchFunc) => { - const res = await Client4.executeAppCall(call) as AppCallResponse; + try { + const res = await Client4.executeAppCall(call) as AppCallResponse; - const responseType = res.type || AppCallResponseTypes.OK; + const responseType = res.type || AppCallResponseTypes.OK; - switch (responseType) { - case AppCallResponseTypes.OK: - if (res.markdown) { - if (!call.context.channel_id) { - return {data: res}; - } + switch (responseType) { + case AppCallResponseTypes.OK: + if (res.markdown) { + if (!call.context.channel_id) { + return {data: res}; + } - ephemeral(res.markdown, call); - } - return {data: res}; - case AppCallResponseTypes.ERROR: - return {data: res}; - case AppCallResponseTypes.FORM: - if (!res.form) { - const errMsg = 'An error has occurred. Please contact the App developer. Details: Response type is `form`, but no form was included in response.'; - ephemeral(errMsg, call); + ephemeral(res.markdown, call); + } return {data: res}; - } + case AppCallResponseTypes.ERROR: + return {data: res}; + case AppCallResponseTypes.FORM: + if (!res.form) { + const errMsg = 'An error has occurred. Please contact the App developer. Details: Response type is `form`, but no form was included in response.'; + ephemeral(errMsg, call); + return {data: res}; + } - if (call.type === AppCallTypes.SUBMIT) { - dispatch(openAppsModal(res.form, call)); - } + if (call.type === AppCallTypes.SUBMIT) { + dispatch(openAppsModal(res.form, call)); + } - return {data: res}; - case AppCallResponseTypes.NAVIGATE: - if (!res.url) { - const errMsg = 'An error has occurred. Please contact the App developer. Details: Response type is `navigate`, but no url was included in response.'; - ephemeral(errMsg, call); return {data: res}; - } + case AppCallResponseTypes.NAVIGATE: + if (!res.url) { + const errMsg = 'An error has occurred. Please contact the App developer. Details: Response type is `navigate`, but no url was included in response.'; + ephemeral(errMsg, call); + return {data: res}; + } - if (shouldOpenInNewTab(res.url, getSiteURL())) { - window.open(res.url); + if (shouldOpenInNewTab(res.url, getSiteURL())) { + window.open(res.url); + return {data: res}; + } + + browserHistory.push(res.url); return {data: res}; } - - browserHistory.push(res.url); return {data: res}; + } catch (error) { + let msg = 'Received an unexpected error.'; + if (error.message) { + msg = error.message; + } + ephemeral(msg, call); + return {data: { + error: msg, + }}; } - - return {data: res}; }; } diff --git a/actions/command.test.js b/actions/command.test.js index f191bc2c0d61..4b8bae96848d 100644 --- a/actions/command.test.js +++ b/actions/command.test.js @@ -24,6 +24,13 @@ const currentTeamId = '321'; const currentUserId = 'user123'; const initialState = { entities: { + admin: { + pluginStatuses: { + 'com.mattermost.apps': { + state: 2, + }, + }, + }, general: { config: { ExperimentalViewArchivedChannels: 'false', diff --git a/actions/command.ts b/actions/command.ts index 4a12c88147e3..bbe1d43c6405 100644 --- a/actions/command.ts +++ b/actions/command.ts @@ -18,7 +18,7 @@ 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, appsEnabled} from 'utils/utils.jsx'; import * as UserAgent from 'utils/user_agent'; import {Constants, ModalIdentifiers} from 'utils/constants'; import {browserHistory} from 'utils/browser_history'; @@ -102,26 +102,28 @@ export function executeCommand(message: string, args: CommandArgs): ActionFunc { dispatch(PostActions.resetEmbedVisibility()); } - const getGlobalState = () => getState() as GlobalState; - - const parser = new AppCommandParser({dispatch, getState: getGlobalState}, args.root_id); - if (parser.isAppCommand(msg)) { - try { - const call = await parser.composeCallFromCommandString(msg); - if (!call) { - return {error: new Error('Error composing command submission')}; - } - const binding = await parser.getBindingWithForm(msg); - if (!binding) { - return {error: new Error('Error fetching binding for command')}; + if (appsEnabled(state)) { + const getGlobalState = () => getState() as GlobalState; + + const parser = new AppCommandParser({dispatch, getState: getGlobalState}, args.root_id); + if (parser.isAppCommand(msg)) { + try { + const call = await parser.composeCallFromCommandString(msg); + if (!call) { + return {error: new Error('Error composing command submission')}; + } + const binding = await parser.getBindingWithForm(msg); + if (!binding) { + return {error: new Error('Error fetching binding for command')}; + } + + return dispatch(doAppCall({ + ...call, + type: AppCallTypes.SUBMIT, + })); + } catch (err) { + return {error: err}; } - - return dispatch(doAppCall({ - ...call, - type: AppCallTypes.SUBMIT, - })); - } catch (err) { - return {error: err}; } } diff --git a/actions/global_actions.tsx b/actions/global_actions.tsx index 325a0d2ca53d..76c8077cd65a 100644 --- a/actions/global_actions.tsx +++ b/actions/global_actions.tsx @@ -96,7 +96,9 @@ export function emitChannelClickEvent(channel: Channel) { member: member || {}, }])); - dispatch(fetchAppBindings(userId, chan.id)); + if (Utils.appsEnabled(state)) { + dispatch(fetchAppBindings(userId, chan.id)); + } } if (channel.fake) { diff --git a/actions/marketplace.ts b/actions/marketplace.ts index a31a1ad59f6e..e4a56c67a917 100644 --- a/actions/marketplace.ts +++ b/actions/marketplace.ts @@ -14,7 +14,7 @@ import {GlobalState} from 'types/store'; import {getApp, getFilter, getPlugin} from 'selectors/views/marketplace'; import {ActionTypes} from 'utils/constants'; -import {shouldProcessApps} from 'utils/utils'; +import {appsEnabled} from 'utils/utils'; import {isError} from 'types/actions'; @@ -44,7 +44,7 @@ export function fetchListing(localOnly = false): ActionFunc { plugins, }); - if (shouldProcessApps(state)) { + if (appsEnabled(state)) { try { apps = await Client4.getMarketplaceApps(filter); } catch (error) { diff --git a/components/dot_menu/dot_menu.test.tsx b/components/dot_menu/dot_menu.test.tsx index eec3ee94f236..8f4015e55317 100644 --- a/components/dot_menu/dot_menu.test.tsx +++ b/components/dot_menu/dot_menu.test.tsx @@ -43,6 +43,7 @@ describe('components/dot_menu/DotMenu', () => { canDelete: false, appBindings: [], pluginMenuItems: [], + appsEnabled: false, }; test('should match snapshot, on Center', () => { diff --git a/components/dot_menu/dot_menu.tsx b/components/dot_menu/dot_menu.tsx index 19004ac4ccb7..61b3728bf611 100644 --- a/components/dot_menu/dot_menu.tsx +++ b/components/dot_menu/dot_menu.tsx @@ -45,6 +45,7 @@ type Props = { 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 @@ -328,21 +329,25 @@ export default class DotMenu extends React.PureComponent { ); }) || []; - const appBindings = this.props.appBindings.map((item) => { - let icon: JSX.Element | undefined; - if (item.icon) { - icon = (); - } + let appBindings = [] as JSX.Element[]; + if (this.props.appsEnabled) { + appBindings = this.props.appBindings.map((item) => { + let icon: JSX.Element | undefined; + if (item.icon) { + icon = (); + } + + return ( + this.onClickAppBinding(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; } diff --git a/components/dot_menu/dot_menu_empty.test.tsx b/components/dot_menu/dot_menu_empty.test.tsx index 6aaf57cbd9a6..ec656c341f01 100644 --- a/components/dot_menu/dot_menu_empty.test.tsx +++ b/components/dot_menu/dot_menu_empty.test.tsx @@ -46,6 +46,7 @@ describe('components/dot_menu/DotMenu returning empty ("")', () => { canDelete: false, appBindings: [], pluginMenuItems: [], + appsEnabled: false, }; const wrapper: ShallowWrapper = shallow( diff --git a/components/dot_menu/dot_menu_mobile.test.tsx b/components/dot_menu/dot_menu_mobile.test.tsx index 7f07222b8e00..a5c343711a0d 100644 --- a/components/dot_menu/dot_menu_mobile.test.tsx +++ b/components/dot_menu/dot_menu_mobile.test.tsx @@ -46,6 +46,7 @@ describe('components/dot_menu/DotMenu on mobile view', () => { canDelete: false, appBindings: [], pluginMenuItems: [], + appsEnabled: false, }; const wrapper: ShallowWrapper = shallow( diff --git a/components/dot_menu/index.ts b/components/dot_menu/index.ts index 7391dfde6656..29f0cfdb8d39 100644 --- a/components/dot_menu/index.ts +++ b/components/dot_menu/index.ts @@ -32,6 +32,8 @@ import * as PostUtils from 'utils/post_utils.jsx'; import {isArchivedChannel} from 'utils/channel_utils'; import {getSiteURL} from 'utils/url'; +import {appsEnabled} from 'utils/utils'; + import DotMenu from './dot_menu'; type Props = { @@ -57,7 +59,8 @@ function mapStateToProps(state: GlobalState, ownProps: Props) { const currentTeam = getCurrentTeam(state) || {}; const currentTeamUrl = `${getSiteURL()}/${currentTeam.name}`; - const appBindings = getAppBindings(state, AppBindingLocations.POST_MENU_ITEM); + const apps = appsEnabled(state); + const appBindings = apps ? getAppBindings(state, AppBindingLocations.POST_MENU_ITEM) : []; return { channelIsArchived: isArchivedChannel(channel), @@ -70,6 +73,7 @@ function mapStateToProps(state: GlobalState, ownProps: Props) { canDelete: PostUtils.canDeletePost(state, post, channel), currentTeamUrl, appBindings, + appsEnabled: apps, ...ownProps, }; } diff --git a/components/post_view/post_body_additional_content/index.ts b/components/post_view/post_body_additional_content/index.ts index e9dc436fa766..db1ef263ec51 100644 --- a/components/post_view/post_body_additional_content/index.ts +++ b/components/post_view/post_body_additional_content/index.ts @@ -10,6 +10,8 @@ import {isEmbedVisible} from 'selectors/posts'; import {GlobalState} from 'types/store'; import {PostWillRenderEmbedPluginComponent} from 'types/store/plugins'; +import {appsEnabled} from 'utils/utils'; + import PostBodyAdditionalContent, { Props, } from './post_body_additional_content'; @@ -18,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 14b70bf267f7..687677910aa2 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 ce4d14568268..88c74d95dcb0 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 @@ -22,6 +22,7 @@ export type Props = { children?: JSX.Element; isEmbedVisible?: boolean; options?: Partial; + appsEnabled: boolean; actions: { toggleEmbedVisibility: (id: string) => void; }; @@ -132,20 +133,22 @@ export default class PostBodyAdditionalContent extends React.PureComponent - {this.props.children} - { /* Do nothing */ }} - /> - - ); + if (this.props.appsEnabled) { + if (hasValidEmbeddedForm(this.props.post.props)) { + // TODO Put some log / message if the form is not valid? + return ( + + {this.props.children} + { /* Do nothing */ }} + /> + + ); + } } if (embed) { diff --git a/components/suggestion/command_provider/command_provider.tsx b/components/suggestion/command_provider/command_provider.tsx index 066b47c8ccd6..613f05083cbf 100644 --- a/components/suggestion/command_provider/command_provider.tsx +++ b/components/suggestion/command_provider/command_provider.tsx @@ -86,7 +86,7 @@ type ResultsCallback = (results: Results) => void; export default class CommandProvider extends Provider { private isInRHS: boolean; private store: Store; - private parser: AppCommandParser; + private appCommandParser?: AppCommandParser; constructor(props: Props) { super(); @@ -101,7 +101,9 @@ export default class CommandProvider extends Provider { } } - this.parser = new AppCommandParser(this.store, rootId); + if (Utils.appsEnabled(this.store.getState())) { + this.appCommandParser = new AppCommandParser(this.store, rootId); + } } handlePretextChanged(pretext: string, resultCallback: ResultsCallback) { @@ -110,8 +112,8 @@ export default class CommandProvider extends Provider { } const command = pretext.toLowerCase(); - if (this.parser.isAppCommand(command)) { - this.parser.getSuggestionsForSubCommandsAndArguments(command).then((matches) => { + if (this.appCommandParser?.isAppCommand(command)) { + this.appCommandParser.getSuggestionsForSubCommandsAndArguments(command).then((matches) => { const terms = matches.map((suggestion) => suggestion.complete); resultCallback({ matchedPretext: command, @@ -140,8 +142,10 @@ export default class CommandProvider extends Provider { Client4.getCommandsList(getCurrentTeamId(this.store.getState())).then( (data) => { let matches: AutocompleteSuggestion[] = []; - const appCommandSuggestions = this.parser.getSuggestionsForBaseCommands(pretext); - matches = matches.concat(appCommandSuggestions); + if (this.appCommandParser) { + const appCommandSuggestions = this.appCommandParser.getSuggestionsForBaseCommands(pretext); + matches = matches.concat(appCommandSuggestions); + } data.forEach((cmd) => { if (!cmd.auto_complete) { @@ -207,8 +211,10 @@ export default class CommandProvider extends Provider { cmd = '⌘'; } - const appCommandSuggestions = this.parser.getSuggestionsForBaseCommands(pretext); - matches = matches.concat(appCommandSuggestions); + if (this.appCommandParser) { + const appCommandSuggestions = this.appCommandParser.getSuggestionsForBaseCommands(pretext); + matches = matches.concat(appCommandSuggestions); + } data.forEach((s) => { if (!this.contains(matches, '/' + s.Complete)) { diff --git a/package-lock.json b/package-lock.json index 6baa5c0b3c66..be7e51932bbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12754,7 +12754,10 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/full-icu/-/full-icu-1.3.1.tgz", "integrity": "sha512-VMtK//85QJomhk3cXOCksNwOYaw1KWnYTS37GYGgyf7A3ajdBoPGhaJuJWAH2S2kq8GZeXkdKn+3Mfmgy11cVw==", - "dev": true + "dev": true, + "requires": { + "icu4c-data": "0.64.2" + } }, "function-bind": { "version": "1.1.1", @@ -13658,6 +13661,12 @@ "postcss": "^7.0.14" } }, + "icu4c-data": { + "version": "0.64.2", + "resolved": "https://registry.npmjs.org/icu4c-data/-/icu4c-data-0.64.2.tgz", + "integrity": "sha512-BPuTfkRTkplmK1pNrqgyOLJ0qB2UcQ12EotVLwiWh4ErtZR1tEYoRZk/LBLmlDfK5v574/lQYLB4jT9vApBiBQ==", + "dev": true + }, "identity-obj-proxy": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", diff --git a/plugins/channel_header_plug/__snapshots__/channel_header_plug.test.tsx.snap b/plugins/channel_header_plug/__snapshots__/channel_header_plug.test.tsx.snap index dfedfd151c5d..b5621db63fb1 100644 --- a/plugins/channel_header_plug/__snapshots__/channel_header_plug.test.tsx.snap +++ b/plugins/channel_header_plug/__snapshots__/channel_header_plug.test.tsx.snap @@ -8,6 +8,7 @@ exports[`plugins/ChannelHeaderPlug should match snapshot with no extended compon } } appBindings={Array []} + appsEnabled={false} channel={Object {}} channelMember={Object {}} components={Array []} @@ -23,6 +24,7 @@ exports[`plugins/ChannelHeaderPlug should match snapshot with one extended compo } } appBindings={Array []} + appsEnabled={false} channel={Object {}} channelMember={Object {}} components={ @@ -126,6 +128,7 @@ exports[`plugins/ChannelHeaderPlug should match snapshot with six extended compo } } appBindings={Array []} + appsEnabled={false} channel={Object {}} channelMember={Object {}} components={ diff --git a/plugins/channel_header_plug/channel_header_plug.test.tsx b/plugins/channel_header_plug/channel_header_plug.test.tsx index 661c00b6c1f4..ee07d501c29d 100644 --- a/plugins/channel_header_plug/channel_header_plug.test.tsx +++ b/plugins/channel_header_plug/channel_header_plug.test.tsx @@ -32,6 +32,7 @@ describe('plugins/ChannelHeaderPlug', () => { doAppCall: jest.fn(), }} appBindings={[]} + appsEnabled={false} />, ); expect(wrapper).toMatchSnapshot(); @@ -48,6 +49,7 @@ describe('plugins/ChannelHeaderPlug', () => { doAppCall: jest.fn(), }} appBindings={[]} + appsEnabled={false} />, ); expect(wrapper).toMatchSnapshot(); @@ -71,6 +73,7 @@ describe('plugins/ChannelHeaderPlug', () => { doAppCall: jest.fn(), }} appBindings={[]} + appsEnabled={false} />, ); expect(wrapper).toMatchSnapshot(); diff --git a/plugins/channel_header_plug/channel_header_plug.tsx b/plugins/channel_header_plug/channel_header_plug.tsx index 7f2ba4a4c7a3..9d09290cd201 100644 --- a/plugins/channel_header_plug/channel_header_plug.tsx +++ b/plugins/channel_header_plug/channel_header_plug.tsx @@ -94,6 +94,7 @@ class CustomToggle extends React.PureComponent { type ChannelHeaderPlugProps = { components: PluginComponent[]; appBindings: AppBinding[]; + appsEnabled: boolean; channel: Channel; channelMember: ChannelMembership; theme: Theme; @@ -196,32 +197,35 @@ export default class ChannelHeaderPlug extends React.PureComponent binding.call).map((binding) => { - return ( -
  • - this.fireActionAndClose(() => this.props.actions.doAppCall({ - ...binding.call, - url: binding?.call?.url || '', - type: AppCallTypes.SUBMIT, - context: { - app_id: binding.app_id, - location: binding.location, - team_id: this.props.channel.team_id, - channel_id: this.props.channel.id, - }, - }))} + let items = componentItems; + if (this.props.appsEnabled) { + items = componentItems.concat(appBindings.filter((binding) => binding.call).map((binding) => { + return ( +
  • - {()} - {binding.label} - -
  • - ); - })); + this.fireActionAndClose(() => this.props.actions.doAppCall({ + ...binding.call, + url: binding?.call?.url || '', + type: AppCallTypes.SUBMIT, + context: { + app_id: binding.app_id, + location: binding.location, + team_id: this.props.channel.team_id, + channel_id: this.props.channel.id, + }, + }))} + > + {()} + {binding.label} + + + ); + })); + } return (
    @@ -277,13 +281,15 @@ export default class ChannelHeaderPlug extends React.PureComponent plug.icon && plug.action).map(this.createComponentButton); - return componentButtons.concat(appBindings.map(this.createAppBindingButton)); + let componentButtons = components.filter((plug) => plug.icon && plug.action).map(this.createComponentButton); + if (this.props.appsEnabled) { + componentButtons = componentButtons.concat(appBindings.map(this.createAppBindingButton)); + } + return componentButtons; } return this.createDropdown(components, appBindings); diff --git a/plugins/channel_header_plug/index.ts b/plugins/channel_header_plug/index.ts index 1065ff86d1fc..44f2189a942c 100644 --- a/plugins/channel_header_plug/index.ts +++ b/plugins/channel_header_plug/index.ts @@ -2,17 +2,36 @@ // See LICENSE.txt for license information. import {connect} from 'react-redux'; +import {bindActionCreators, Dispatch} from 'redux'; + import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {getAppBindings} from 'mattermost-redux/selectors/entities/apps'; +import {AppBindingLocations} from 'mattermost-redux/constants/apps'; +import {GenericAction} from 'mattermost-redux/types/actions'; +import {doAppCall} from 'actions/apps'; import {GlobalState} from 'types/store'; +import {appsEnabled} from 'utils/utils'; + import ChannelHeaderPlug from './channel_header_plug'; function mapStateToProps(state: GlobalState) { + const apps = appsEnabled(state); return { components: state.plugins.components.ChannelHeaderButton || [], + appBindings: apps ? getAppBindings(state, AppBindingLocations.CHANNEL_HEADER_ICON) : [], + appsEnabled: apps, theme: getTheme(state), }; } -export default connect(mapStateToProps)(ChannelHeaderPlug); +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + doAppCall, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ChannelHeaderPlug); diff --git a/utils/utils.jsx b/utils/utils.jsx index ab4027cfaa38..88a6ad882660 100644 --- a/utils/utils.jsx +++ b/utils/utils.jsx @@ -16,6 +16,8 @@ import { } from 'mattermost-redux/utils/theme_utils'; import {displayUsername} from 'mattermost-redux/utils/user_utils'; import {getCurrentTeamId, getCurrentRelativeTeamUrl, getTeam} from 'mattermost-redux/selectors/entities/teams'; + +//import PluginStates from 'mattermost-redux/constants/plugins'; import cssVars from 'css-vars-ponyfill'; import moment from 'moment'; @@ -2156,7 +2158,7 @@ export function stringToNumber(s) { return parseInt(s, 10); } -export function shouldProcessApps(state) {// eslint-disable-line no-unused-vars +export function appsEnabled(state) {// eslint-disable-line no-unused-vars // TODO uncomment when featur flag is in place //return getConfig(state)?.['FeatureFlagApps' as keyof Partial]; return true;