diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx index 774c746513..819478ee0d 100644 --- a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx @@ -22,12 +22,14 @@ import {getGroupMessageMembersCommonTeams} from 'actions/team_actions'; import {trackEvent} from 'actions/telemetry_actions'; import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field'; -import type {Actions} from 'components/convert_gm_to_channel_modal/index'; -import NoCommonTeamsError from 'components/convert_gm_to_channel_modal/no_common_teams/no_common_teams'; -import TeamSelector from 'components/convert_gm_to_channel_modal/team_selector/team_selector'; -import WarningTextSection from 'components/convert_gm_to_channel_modal/warning_text_section/warning_text_section'; import LoadingSpinner from 'components/widgets/loading/loading_spinner'; +import NoCommonTeamsError from './no_common_teams/no_common_teams'; +import TeamSelector from './team_selector/team_selector'; +import WarningTextSection from './warning_text_section/warning_text_section'; + +import type {Actions} from './index'; + const enum ServerErrorId { CHANNEL_NAME_EXISTS = 'store.sql_channel.save_channel.exists.app_error', } diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/no_common_teams/no_common_teams.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/no_common_teams/no_common_teams.tsx index 62e5615a2b..9d30fc20a3 100644 --- a/webapp/channels/src/components/convert_gm_to_channel_modal/no_common_teams/no_common_teams.tsx +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/no_common_teams/no_common_teams.tsx @@ -2,29 +2,24 @@ // See LICENSE.txt for license information. import React from 'react'; -import {FormattedMessage} from 'react-intl'; +import {useIntl} from 'react-intl'; -import 'components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss'; +import SectionNotice from 'components/section_notice'; const NoCommonTeamsError = (): JSX.Element => { + const intl = useIntl(); return ( -
- -
-
- -
-
- -
-
-
+ ); }; diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss b/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss deleted file mode 100644 index 3ecf2179f2..0000000000 --- a/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss +++ /dev/null @@ -1,35 +0,0 @@ -.warning-section { - display: flex; - padding: 16px; - border: 1px solid rgba(var(--sidebar-text-active-border-rgb), 0.16); - background: rgba(var(--sidebar-text-active-border-rgb), 0.08); - border-radius: 4px; - gap: 12px; - - &.error { - border: 1px solid rgba(var(--dnd-indicator-rgb), 0.16); - background: rgba(var(--dnd-indicator-rgb), 0.08); - - .fa.fa-exclamation-circle { - color: rgba(var(--dnd-indicator-rgb), 1); - } - } - - .fa.fa-exclamation-circle { - width: 24px; - height: 24px; - color: rgba(var(--sidebar-text-active-border-rgb), 1); - font-size: 24px; - } - - .warning-text { - display: flex; - flex-direction: column; - color: var(--center-channel-color-88); - gap: 8px; - } - - .warning-header { - font-weight: bold; - } -} diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.tsx index 7a9c8b067e..97728f3a3c 100644 --- a/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.tsx +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.tsx @@ -2,9 +2,9 @@ // See LICENSE.txt for license information. import React from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; +import {useIntl} from 'react-intl'; -import 'components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss'; +import SectionNotice from 'components/section_notice'; export type Props = { channelMemberNames: string[]; @@ -21,26 +21,19 @@ const WarningTextSection = (props: Props): JSX.Element => { } return ( -
- -
-
- -
-
- -
-
-
+ ); }; export default WarningTextSection; diff --git a/webapp/channels/src/components/section_notice.scss b/webapp/channels/src/components/section_notice.scss new file mode 100644 index 0000000000..0d14826d0f --- /dev/null +++ b/webapp/channels/src/components/section_notice.scss @@ -0,0 +1,120 @@ +.sectionNoticeContainer { + position: relative; + border-width: 1px; + border-style: solid; + border-radius: 4px; + + p { + margin: 0; + } + + &.info { + border-color: rgba(var(--sidebar-text-active-border-rgb), 0.16); + background: rgba(var(--sidebar-text-active-border-rgb), 0.08); + + p { + color: var(--center-channel-color); + } + } + + &.success { + border-color: rgba(var(--online-indicator-rgb), 0.16); + background: rgba(var(--online-indicator-rgb), 0.08); + + p { + color: var(--center-channel-color); + } + } + + &.danger { + border-color: rgba(var(--dnd-indicator-rgb), 0.16); + background: rgba(var(--dnd-indicator-rgb), 0.08); + + p { + color: var(--center-channel-color); + } + } + + &.welcome { + border-color: rgba(var(--center-channel-color-rgb), 0.08); + background: rgba(var(--center-channel-color-rgb), 0.04); + + p { + color: rgba(var(--center-channel-color-rgb), 0.72); + } + } + + &.warning { + border-color: rgba(var(--away-indicator-rgb), 0.16); + background: rgba(var(--away-indicator-rgb), 0.08); + + p { + color: var(--center-channel-color); + } + } +} + +.sectionNoticeActions { + display: flex; + flex-flow: wrap; +} + +.sectionNoticeClose { + position: absolute; + top: 10px; + right: 10px; +} + +.sectionNoticeContent { + display: flex; + flex-direction: row; + align-items: start; + padding: 16px; + gap: 12px; +} + +.sectionNoticeIcon { + font-size: 20px; + + &.info { + color: var(--sidebar-text-active-border); + } + + &.success { + color: var(--online-indicator); + } + + &.danger { + color: var(--dnd-indicator); + } + + &.warning { + color: var(--away-indicator); + } +} + +.sectionNoticeTitle { + margin: 0; + color: var(--center-channel-color); + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 600; + line-height: 20px; + + &.welcome { + font-family: 'Metropolis'; + font-size: 20px; + line-height: 28px; + } +} + +.sectionNoticeBody { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sectionNoticeButton { + margin-top: 4px; + margin-bottom: 4px; +} diff --git a/webapp/channels/src/components/section_notice.test.tsx b/webapp/channels/src/components/section_notice.test.tsx new file mode 100644 index 0000000000..f59bce9364 --- /dev/null +++ b/webapp/channels/src/components/section_notice.test.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, screen} from '@testing-library/react'; +import type {ComponentProps} from 'react'; +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import SectionNotice from './section_notice'; + +type Props = ComponentProps; + +function getBaseProps(): Props { + return { + text: 'some text', + title: 'some title', + primaryButton: { + onClick: jest.fn(), + text: 'primary button title', + }, + secondaryButton: { + onClick: jest.fn(), + text: 'secondary button title', + }, + linkButton: { + onClick: jest.fn(), + text: 'link button title', + }, + isDismissable: true, + onDismissClick: jest.fn(), + type: 'info', + }; +} + +describe('PluginAction', () => { + it('does show the correct information', () => { + const props = getBaseProps(); + renderWithContext(); + const primaryButton = screen.getByText(props.primaryButton!.text); + const secondaryButton = screen.getByText(props.secondaryButton!.text); + const linkButton = screen.getByText(props.linkButton!.text); + const closeButton = screen.getByLabelText('Dismiss notice'); + + expect(primaryButton).toBeInTheDocument(); + expect(secondaryButton).toBeInTheDocument(); + expect(linkButton).toBeInTheDocument(); + expect(closeButton).toBeInTheDocument(); + expect(screen.queryByText(props.text)).toBeInTheDocument(); + expect(screen.queryByText(props.title)).toBeInTheDocument(); + fireEvent.click(primaryButton); + expect(props.primaryButton?.onClick).toHaveBeenCalledTimes(1); + fireEvent.click(secondaryButton); + expect(props.secondaryButton?.onClick).toHaveBeenCalledTimes(1); + fireEvent.click(linkButton); + expect(props.linkButton?.onClick).toHaveBeenCalledTimes(1); + fireEvent.click(closeButton); + expect(props.onDismissClick).toHaveBeenCalledTimes(1); + }); + + it('does not show the button if no button is passed', () => { + const props = getBaseProps(); + props.primaryButton = undefined; + props.secondaryButton = undefined; + props.linkButton = undefined; + props.isDismissable = false; + renderWithContext(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/section_notice.tsx b/webapp/channels/src/components/section_notice.tsx new file mode 100644 index 0000000000..27d8a05e0d --- /dev/null +++ b/webapp/channels/src/components/section_notice.tsx @@ -0,0 +1,101 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React from 'react'; +import {useIntl} from 'react-intl'; + +import Markdown from 'components/markdown'; + +import './section_notice.scss'; + +type Button = { + onClick: () => void; + text: string; +} +type Props = { + title: string; + text: string; + primaryButton?: Button; + secondaryButton?: Button; + linkButton?: Button; + type?: 'info' | 'success' | 'danger' | 'welcome' | 'warning'; + isDismissable?: boolean; + onDismissClick?: () => void; +}; + +const iconByType = { + info: 'icon-information-outline', + success: 'icon-check', + danger: 'icon-alert-outline', + warning: 'icon-alert-outline', + welcome: undefined, +}; + +const SectionNotice = ({ + title, + text, + primaryButton, + secondaryButton, + linkButton, + type = 'info', + isDismissable, + onDismissClick, +}: Props) => { + const intl = useIntl(); + const icon = iconByType[type]; + const showDismiss = Boolean(isDismissable && onDismissClick); + const buttonClass = 'btn btn-sm sectionNoticeButton'; + return ( +
+
+ {icon && } +
+

{title}

+ +
+ {primaryButton && + + } + {secondaryButton && + + } + {linkButton && + + } +
+ +
+
+ {showDismiss && + + } +
+ ); +}; + +export default SectionNotice; diff --git a/webapp/channels/src/components/user_settings/modal/user_settings_modal.test.tsx b/webapp/channels/src/components/user_settings/modal/user_settings_modal.test.tsx index f874cd3a04..d27e72f946 100644 --- a/webapp/channels/src/components/user_settings/modal/user_settings_modal.test.tsx +++ b/webapp/channels/src/components/user_settings/modal/user_settings_modal.test.tsx @@ -18,7 +18,7 @@ import UserSettingsModal from './index'; type Props = ComponentProps; const baseProps: Props = { - isContentProductSettings: false, + isContentProductSettings: true, onExited: jest.fn(), }; @@ -39,6 +39,40 @@ describe('do first render to avoid other testing issues', () => { renderWithContext(, baseState); }); +describe('plugin tabs are only rendered on content product settings', () => { + it('plugin tabs are properly rendered', async () => { + const uiName1 = 'plugin_a'; + const uiName2 = 'plugin_b'; + const state: DeepPartial = { + plugins: { + userSettings: { + plugin_a: { + id: 'plugin_a', + sections: [], + uiName: uiName1, + }, + plugin_b: { + id: 'plugin_b', + sections: [], + uiName: uiName2, + }, + }, + }, + }; + + renderWithContext( + , + mergeObjects(baseState, state), + ); + + expect(screen.queryByText(uiName1)).not.toBeInTheDocument(); + expect(screen.queryByText(uiName2)).not.toBeInTheDocument(); + }); +}); + describe('tabs are properly rendered', () => { it('plugin tabs are properly rendered', async () => { const uiName1 = 'plugin_a'; diff --git a/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx b/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx index 54aab8106b..475a44dd8b 100644 --- a/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx +++ b/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx @@ -280,11 +280,18 @@ class UserSettingsModal extends React.PureComponent { return (
); } const tabs = []; + let pluginTabs; if (this.props.isContentProductSettings) { tabs.push({name: 'notifications', uiName: formatMessage(holders.notifications), icon: 'icon icon-bell-outline', iconTitle: Utils.localizeMessage('user.settings.notifications.icon', 'Notification Settings Icon')}); tabs.push({name: 'display', uiName: formatMessage(holders.display), icon: 'icon icon-eye-outline', iconTitle: Utils.localizeMessage('user.settings.display.icon', 'Display Settings Icon')}); tabs.push({name: 'sidebar', uiName: formatMessage(holders.sidebar), icon: 'icon icon-dock-left', iconTitle: Utils.localizeMessage('user.settings.sidebar.icon', 'Sidebar Settings Icon')}); tabs.push({name: 'advanced', uiName: formatMessage(holders.advanced), icon: 'icon icon-tune', iconTitle: Utils.localizeMessage('user.settings.advance.icon', 'Advanced Settings Icon')}); + pluginTabs = Object.values(this.props.pluginSettings).map((v) => ({ + icon: v.icon ? {url: v.icon} : 'icon-power-plug-outline', + iconTitle: v.uiName, + name: v.id, + uiName: v.uiName, + })); } else { tabs.push({name: 'profile', uiName: formatMessage(holders.profile), icon: 'icon icon-settings-outline', iconTitle: Utils.localizeMessage('user.settings.profile.icon', 'Profile Settings Icon')}); tabs.push({name: 'security', uiName: formatMessage(holders.security), icon: 'icon icon-lock-outline', iconTitle: Utils.localizeMessage('user.settings.security.icon', 'Security Settings Icon')}); @@ -327,12 +334,7 @@ class UserSettingsModal extends React.PureComponent { ({ - icon: v.icon ? {url: v.icon} : 'icon-power-plug-outline', - iconTitle: v.uiName, - name: v.id, - uiName: v.uiName, - }))} + pluginTabs={pluginTabs} activeTab={this.state.active_tab} updateTab={this.updateTab} /> diff --git a/webapp/channels/src/components/user_settings/plugin/__snapshots__/plugin.test.tsx.snap b/webapp/channels/src/components/user_settings/plugin/__snapshots__/plugin.test.tsx.snap index 66eaa322c2..69107f5bef 100644 --- a/webapp/channels/src/components/user_settings/plugin/__snapshots__/plugin.test.tsx.snap +++ b/webapp/channels/src/components/user_settings/plugin/__snapshots__/plugin.test.tsx.snap @@ -13,6 +13,16 @@ exports[`plugin tab all props are properly passed to the children 1`] = ` +
diff --git a/webapp/channels/src/components/user_settings/plugin/plugin.test.tsx b/webapp/channels/src/components/user_settings/plugin/plugin.test.tsx index 9f524ad87c..8394c7be46 100644 --- a/webapp/channels/src/components/user_settings/plugin/plugin.test.tsx +++ b/webapp/channels/src/components/user_settings/plugin/plugin.test.tsx @@ -18,6 +18,12 @@ const baseProps: Props = { collapseModal: jest.fn(), settings: { id: 'pluginA', + action: { + text: 'actionText', + buttonText: 'buttonText', + onClick: jest.fn(), + title: 'actionTitle', + }, sections: [ { settings: [ diff --git a/webapp/channels/src/components/user_settings/plugin/plugin.tsx b/webapp/channels/src/components/user_settings/plugin/plugin.tsx index 090954e4b6..c1dbb5e6c7 100644 --- a/webapp/channels/src/components/user_settings/plugin/plugin.tsx +++ b/webapp/channels/src/components/user_settings/plugin/plugin.tsx @@ -6,6 +6,7 @@ import {useIntl} from 'react-intl'; import type {PluginConfiguration} from 'types/plugins/user_settings'; +import PluginAction from './plugin_action'; import PluginSetting from './plugin_setting'; import SettingDesktopHeader from '../setting_desktop_header'; @@ -42,6 +43,7 @@ const PluginTab = ({ />
+
{settings.sections.map( (v) => diff --git a/webapp/channels/src/components/user_settings/plugin/plugin_action.scss b/webapp/channels/src/components/user_settings/plugin/plugin_action.scss new file mode 100644 index 0000000000..e781904ff4 --- /dev/null +++ b/webapp/channels/src/components/user_settings/plugin/plugin_action.scss @@ -0,0 +1,3 @@ +.pluginActionContainer { + margin-bottom: 20px; +} diff --git a/webapp/channels/src/components/user_settings/plugin/plugin_action.test.tsx b/webapp/channels/src/components/user_settings/plugin/plugin_action.test.tsx new file mode 100644 index 0000000000..0ef5103534 --- /dev/null +++ b/webapp/channels/src/components/user_settings/plugin/plugin_action.test.tsx @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, render, screen} from '@testing-library/react'; +import type {ComponentProps} from 'react'; +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import PluginAction from './plugin_action'; + +type Props = ComponentProps; + +function getBaseProps(): Props { + return { + action: { + title: 'some title', + text: 'some text', + buttonText: 'button text', + onClick: jest.fn(), + }, + }; +} + +describe('PluginAction', () => { + it('does not show when no action is provided', () => { + const {container} = render(); + expect(container.firstChild).toBeNull(); + }); + + it('does show the correct information', () => { + const props = getBaseProps(); + renderWithContext(); + const button = screen.getByText(props.action!.buttonText); + expect(button).toBeInTheDocument(); + expect(screen.queryByText(props.action!.text)).toBeInTheDocument(); + expect(screen.queryByText(props.action!.title)).toBeInTheDocument(); + fireEvent.click(button); + expect(props.action?.onClick).toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/user_settings/plugin/plugin_action.tsx b/webapp/channels/src/components/user_settings/plugin/plugin_action.tsx new file mode 100644 index 0000000000..f04b22815d --- /dev/null +++ b/webapp/channels/src/components/user_settings/plugin/plugin_action.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ComponentProps} from 'react'; +import React, {useMemo} from 'react'; + +import SectionNotice from 'components/section_notice'; + +import type {PluginConfigurationAction} from 'types/plugins/user_settings'; + +import './plugin_action.scss'; + +type Props = { + action?: PluginConfigurationAction; +}; + +const PluginAction = ({ + action, +}: Props) => { + const props = useMemo>(() => { + return action ? { + text: action.text, + title: action.title, + primaryButton: { + onClick: action?.onClick, + text: action?.buttonText, + }, + } : { + text: '', + title: '', + }; + }, [action]); + + if (!action) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default PluginAction; diff --git a/webapp/channels/src/components/user_settings/plugin/plugin_setting.test.tsx b/webapp/channels/src/components/user_settings/plugin/plugin_setting.test.tsx index be29b2234b..9d645a5ae6 100644 --- a/webapp/channels/src/components/user_settings/plugin/plugin_setting.test.tsx +++ b/webapp/channels/src/components/user_settings/plugin/plugin_setting.test.tsx @@ -66,6 +66,16 @@ describe('plugin setting', () => { expect(screen.queryByText(OPTION_1_TEXT)).toBeInTheDocument(); }); + it('isDisabled is respected', () => { + const props = getBaseProps(); + props.section.disabled = true; + renderWithContext(); + expect(screen.queryByText('Edit')).not.toBeInTheDocument(); + expect(screen.queryByText(SECTION_TITLE)).toBeInTheDocument(); + fireEvent.click(screen.getByText(SECTION_TITLE)); + expect(screen.queryByText(OPTION_1_TEXT)).not.toBeInTheDocument(); + }); + it('properly take the current value from the preferences', () => { const category = getPluginPreferenceKey(PLUGIN_ID); const prefKey = getPreferenceKey(category, SETTING_1_NAME); diff --git a/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx b/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx index cfeb633790..cb3f8a2247 100644 --- a/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx +++ b/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx @@ -112,6 +112,7 @@ const PluginSetting = ({ title={section.title} updateSection={updateSection} describe={minDescribe} + isDisabled={section.disabled} /> ); }; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 70d34f612c..083970e0de 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4751,6 +4751,7 @@ "search_list_option.on": "Messages on a date", "search_list_option.phrases": "Messages with phrases", "search_results.channel-files-header": "Recent files", + "sectionNotice.dismiss": "Dismiss notice", "select_team.icon": "Select Team Icon", "select_team.join.icon": "Join Team Icon", "select_team.private.icon": "Private Team", diff --git a/webapp/channels/src/types/plugins/user_settings.ts b/webapp/channels/src/types/plugins/user_settings.ts index 6aa5e6875b..e2208c9123 100644 --- a/webapp/channels/src/types/plugins/user_settings.ts +++ b/webapp/channels/src/types/plugins/user_settings.ts @@ -12,6 +12,14 @@ export type PluginConfiguration = { /** URL to the icon to show in the UI. No icon will show the plug outline icon. */ icon?: string; sections: PluginConfigurationSection[]; + action?: PluginConfigurationAction; +} + +export type PluginConfigurationAction = { + title: string; + text: string; + buttonText: string; + onClick: () => void; } export type PluginConfigurationSection = { @@ -20,6 +28,9 @@ export type PluginConfigurationSection = { /** The title of the section. All titles must be different. */ title: string; + /** Whether the section is disabled. */ + disabled?: boolean; + /** * This function will be called whenever a section is saved. * diff --git a/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts b/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts index c058aff9dd..a80871f0ea 100644 --- a/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts +++ b/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts @@ -8,6 +8,12 @@ import {extractPluginConfiguration} from './plugin_setting_extraction'; function getFullExample(): PluginConfiguration { return { id: '', + action: { + buttonText: 'some button text', + onClick: () => 1, + text: 'some text', + title: 'some title', + }, sections: [ { settings: [ @@ -68,6 +74,7 @@ function getFullExample(): PluginConfiguration { ], title: 'title 2', onSubmit: () => 2, + disabled: true, }, ], uiName: 'some name', @@ -99,6 +106,32 @@ describe('plugin setting extraction', () => { expect(res!.id).toBe(pluginId); }); + it('action gets properly added', () => { + const config = getFullExample(); + const res = extractPluginConfiguration(config, 'PluginId'); + expect(res).toBeTruthy(); + expect(res?.action).toBeTruthy(); + expect(res?.action?.buttonText).toBe(config.action?.buttonText); + expect(res?.action?.text).toBe(config.action?.text); + expect(res?.action?.title).toBe(config.action?.title); + expect(res?.action?.onClick).toBe(config.action?.onClick); + }); + + it('sections get properly added', () => { + const config = getFullExample(); + const res = extractPluginConfiguration(config, 'PluginId'); + expect(res).toBeTruthy(); + expect(res?.sections).toHaveLength(2); + expect(res?.sections[0].disabled).toBe(config.sections[0].disabled); + expect(res?.sections[0].title).toBe(config.sections[0].title); + expect(res?.sections[0].onSubmit).toBe(config.sections[0].onSubmit); + expect(res?.sections[0].settings).toHaveLength(config.sections[0].settings.length); + expect(res?.sections[1].disabled).toBe(config.sections[1].disabled); + expect(res?.sections[1].title).toBe(config.sections[1].title); + expect(res?.sections[1].onSubmit).toBe(config.sections[1].onSubmit); + expect(res?.sections[1].settings).toHaveLength(config.sections[1].settings.length); + }); + it('reject configs without name', () => { const config: any = getFullExample(); config.uiName = ''; @@ -233,6 +266,32 @@ describe('plugin setting extraction', () => { expect(res?.sections[0].settings).toHaveLength(1); }); + it('filter out ill defined action', () => { + let config: any = getFullExample(); + delete config.action?.buttonText; + let res = extractPluginConfiguration(config, 'PluginId'); + expect(res).toBeTruthy(); + expect(res?.action).toBeFalsy(); + + config = getFullExample(); + delete config.action?.title; + res = extractPluginConfiguration(config, 'PluginId'); + expect(res).toBeTruthy(); + expect(res?.action).toBeFalsy(); + + config = getFullExample(); + delete config.action?.text; + res = extractPluginConfiguration(config, 'PluginId'); + expect(res).toBeTruthy(); + expect(res?.action).toBeFalsy(); + + config = getFullExample(); + delete config.action?.onClick; + res = extractPluginConfiguration(config, 'PluginId'); + expect(res).toBeTruthy(); + expect(res?.action).toBeFalsy(); + }); + it('(future proof) filter out extra config arguments', () => { const config: any = getFullExample(); config.futureProperty = 'hello'; diff --git a/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx b/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx index 3d65e54948..f7c336679b 100644 --- a/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx +++ b/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {BasePluginConfigurationSetting, PluginConfiguration, PluginConfigurationRadioSetting, PluginConfigurationRadioSettingOption, PluginConfigurationSection} from 'types/plugins/user_settings'; +import type {BasePluginConfigurationSetting, PluginConfiguration, PluginConfigurationAction, PluginConfigurationRadioSetting, PluginConfigurationRadioSettingOption, PluginConfigurationSection} from 'types/plugins/user_settings'; export function extractPluginConfiguration(pluginConfiguration: unknown, pluginId: string) { if (!pluginConfiguration) { @@ -33,11 +33,17 @@ export function extractPluginConfiguration(pluginConfiguration: unknown, pluginI return undefined; } + let action; + if ('action' in pluginConfiguration && pluginConfiguration.action) { + action = extractPluginConfigurationAction(pluginConfiguration.action); + } + const result: PluginConfiguration = { id: pluginId, icon, sections: [], uiName: pluginConfiguration.uiName, + action, }; for (const section of pluginConfiguration.sections) { @@ -54,6 +60,39 @@ export function extractPluginConfiguration(pluginConfiguration: unknown, pluginI return result; } +function extractPluginConfigurationAction(action: unknown): PluginConfigurationAction | undefined { + if (!action) { + return undefined; + } + + if (typeof action !== 'object') { + return undefined; + } + + if (!('title' in action) || !action.title || typeof action.title !== 'string') { + return undefined; + } + + if (!('text' in action) || !action.text || typeof action.text !== 'string') { + return undefined; + } + + if (!('buttonText' in action) || !action.buttonText || typeof action.buttonText !== 'string') { + return undefined; + } + + if (!('onClick' in action) || !action.onClick || typeof action.onClick !== 'function') { + return undefined; + } + + return { + title: action.title, + text: action.text, + buttonText: action.buttonText, + onClick: action.onClick as PluginConfigurationAction['onClick'], + }; +} + function extractPluginConfigurationSection(section: unknown) { if (!section) { return undefined; @@ -84,9 +123,19 @@ function extractPluginConfigurationSection(section: unknown) { } } + let disabled; + if ('disabled' in section && section.disabled) { + if (typeof section.disabled === 'boolean') { + disabled = section.disabled; + } else { + return undefined; + } + } + const result: PluginConfigurationSection = { settings: [], title: section.title, + disabled, onSubmit, };