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 d27e72f946..8b5953be37 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 @@ -99,6 +99,58 @@ describe('tabs are properly rendered', () => { expect(screen.queryByText(uiName1)).toBeInTheDocument(); expect(screen.queryByText(uiName2)).toBeInTheDocument(); }); + + it('plugin settings tabs can be selected', async () => { + const uiName1 = 'plugin A'; + const uiName2 = 'plugin B'; + const state: DeepPartial = { + plugins: { + userSettings: { + plugin_a: { + id: 'plugin_a', + sections: [ + { + title: 'plugin A section', + settings: [ + { + name: 'plugin A setting', + }, + ], + }, + ], + uiName: uiName1, + }, + plugin_b: { + id: 'plugin_b', + sections: [ + { + title: 'plugin B section', + settings: [ + { + name: 'plugin B setting', + }, + ], + }, + ], + uiName: uiName2, + }, + }, + }, + }; + + renderWithContext( + , + mergeObjects(baseState, state), + ); + + expect(screen.queryByText(uiName1)).toBeInTheDocument(); + expect(screen.queryByText(uiName2)).toBeInTheDocument(); + expect(screen.queryAllByText('plugin B Settings')).toHaveLength(2); + expect(screen.queryByText('plugin A Settings')).not.toBeInTheDocument(); + }); }); describe('plugin tabs use the correct icon', () => { @@ -121,12 +173,12 @@ describe('plugin tabs use the correct icon', () => { const element = screen.queryByTitle(uiName); expect(element).toBeInTheDocument(); expect(element!.nodeName).toBe('I'); - expect(element?.className).toBe('icon-power-plug-outline'); + expect(element?.className).toBe('icon icon-power-plug-outline'); }); - it('use image when icon provided', () => { + it('use image when icon URL provided', () => { const uiName = 'plugin_a'; - const icon = 'icon_url'; + const icon = 'http://localhost:8065/plugins/com.mattermost.plugin_a/public/icon.svg'; const state: DeepPartial = { plugins: { userSettings: { @@ -146,4 +198,51 @@ describe('plugin tabs use the correct icon', () => { expect(element!.nodeName).toBe('IMG'); expect(element!.getAttribute('src')).toBe(icon); }); + + it('use image when icon path provided', () => { + const uiName = 'plugin_a'; + const icon = '/plugins/com.mattermost.plugin_a/public/icon.svg'; + const state: DeepPartial = { + plugins: { + userSettings: { + plugin_a: { + id: 'plugin_a', + sections: [], + uiName, + icon, + }, + }, + }, + }; + renderWithContext(, mergeObjects(baseState, state)); + + const element = screen.queryByAltText(uiName); + expect(element).toBeInTheDocument(); + expect(element!.nodeName).toBe('IMG'); + expect(element!.getAttribute('src')).toBe(icon); + }); + + it('use class name when icon name provided', () => { + const uiName = 'plugin_a'; + const icon = 'icon-phone-in-talk'; + const state: DeepPartial = { + plugins: { + userSettings: { + plugin_a: { + id: 'plugin_a', + sections: [], + uiName, + icon, + }, + }, + }, + }; + + renderWithContext(, mergeObjects(baseState, state)); + + const element = screen.queryByTitle(uiName); + expect(element).toBeInTheDocument(); + expect(element!.nodeName).toBe('I'); + expect(element?.className).toBe('icon icon-phone-in-talk'); + }); }); 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 2184a23dc5..9347887098 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 @@ -21,6 +21,7 @@ import SmartLoader from 'components/widgets/smart_loader'; import Constants from 'utils/constants'; import {cmdOrCtrlPressed, isKeyPressed} from 'utils/keyboard'; import {stopTryNotificationRing} from 'utils/notification_sounds'; +import {isValidUrl} from 'utils/url'; import {getDisplayName} from 'utils/utils'; import type {PluginConfiguration} from 'types/plugins/user_settings'; @@ -30,6 +31,7 @@ export type OwnProps = { adminMode?: boolean; isContentProductSettings: boolean; userPreferences?: PreferencesType; + activeTab?: string; } export type Props = OwnProps & { @@ -64,7 +66,7 @@ class UserSettingsModal extends React.PureComponent { super(props); this.state = { - active_tab: props.isContentProductSettings ? 'notifications' : 'profile', + active_tab: props.activeTab ?? (props.isContentProductSettings ? 'notifications' : 'profile'), active_section: '', showConfirmModal: false, enforceFocus: true, @@ -299,12 +301,16 @@ class UserSettingsModal extends React.PureComponent { }; getPluginsSettingsTab = () => { - return Object.values(this.props.pluginSettings).map((v) => ({ - name: v.id, - uiName: v.uiName, - icon: v.icon ? {url: v.icon} : 'icon-power-plug-outline', - iconTitle: v.uiName, - })); + return Object.values(this.props.pluginSettings).map((v) => { + const className = v.icon ? `icon ${v.icon}` : 'icon icon-power-plug-outline'; + const useURL = v.icon && (isValidUrl(v.icon) || v.icon.startsWith('/')); + return { + name: v.id, + uiName: v.uiName, + icon: useURL ? {url: v.icon!} : className, + iconTitle: v.uiName, + }; + }); }; render() { diff --git a/webapp/channels/src/components/user_settings/plugin/index.test.tsx b/webapp/channels/src/components/user_settings/plugin/index.test.tsx index 174e21d991..3fa1ecc222 100644 --- a/webapp/channels/src/components/user_settings/plugin/index.test.tsx +++ b/webapp/channels/src/components/user_settings/plugin/index.test.tsx @@ -12,75 +12,119 @@ import PluginTab from './index'; type Props = ComponentProps; -const baseProps: Props = { - activeSection: '', - closeModal: jest.fn(), - collapseModal: jest.fn(), - settings: { - id: 'pluginA', - action: { - text: 'actionText', - buttonText: 'buttonText', - onClick: jest.fn(), - title: 'actionTitle', +function getBaseProps(): Props { + return { + activeSection: '', + closeModal: jest.fn(), + collapseModal: jest.fn(), + settings: { + id: 'pluginA', + action: { + text: 'actionText', + buttonText: 'buttonText', + onClick: jest.fn(), + title: 'actionTitle', + }, + sections: [ + { + settings: [ + { + default: '0', + name: '0', + options: [ + { + text: 'Option 0', + value: '0', + }, + { + text: 'Option 1', + value: '1', + }, + ], + type: 'radio', + }, + ], + title: 'section 1', + onSubmit: jest.fn(), + }, + { + settings: [ + { + default: '1', + name: '1', + options: [ + { + text: 'Option 0', + value: '0', + }, + { + text: 'Option 1', + value: '1', + }, + ], + type: 'radio', + }, + ], + title: 'section 2', + onSubmit: jest.fn(), + }, + ], + uiName: 'plugin A', }, - sections: [ - { - settings: [ - { - default: '0', - name: '0', - options: [ - { - text: 'Option 0', - value: '0', - }, - { - text: 'Option 1', - value: '1', - }, - ], - type: 'radio', - }, - ], - title: 'section 1', - onSubmit: jest.fn(), - }, - { - settings: [ - { - default: '1', - name: '1', - options: [ - { - text: 'Option 0', - value: '0', - }, - { - text: 'Option 1', - value: '1', - }, - ], - type: 'radio', - }, - ], - title: 'section 2', - onSubmit: jest.fn(), - }, - ], - uiName: 'plugin A', - }, - updateSection: jest.fn(), -}; + updateSection: jest.fn(), + }; +} + +const CUSTOM_SECTION_TEXT = 'custom section content'; + +function CustomSection() { + return (
{CUSTOM_SECTION_TEXT}
); +} + +function CustomSectionThrows() { + const throwError = () => { + throw new Error('component error'); + }; + return (
{throwError()}
); +} describe('plugin tab', () => { it('all props are properly passed to the children', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); it('setting name is properly set', () => { - renderWithContext(); + renderWithContext(); expect(screen.queryAllByText('plugin A Settings')).toHaveLength(2); }); + + it('custom section component', () => { + const props = getBaseProps(); + props.settings.sections.push({ + title: 'custom section', + component: CustomSection, + }); + renderWithContext(); + expect(screen.queryAllByText('plugin A Settings')).toHaveLength(2); + expect(screen.queryByText(CUSTOM_SECTION_TEXT)).toBeInTheDocument(); + }); + + it('custom section component throws', () => { + const consoleError = console.error; + console.error = jest.fn(); + + const props = getBaseProps(); + props.settings.sections.push({ + title: 'custom section', + component: CustomSectionThrows, + }); + renderWithContext(); + expect(screen.queryAllByText('plugin A Settings')).toHaveLength(2); + expect(screen.queryByText(CUSTOM_SECTION_TEXT)).not.toBeInTheDocument(); + expect(screen.queryByText('An error occurred in the pluginA plugin.')).toBeInTheDocument(); + expect(console.error).toHaveBeenCalled(); + + console.error = consoleError; + }); }); diff --git a/webapp/channels/src/components/user_settings/plugin/index.tsx b/webapp/channels/src/components/user_settings/plugin/index.tsx index 61a51d14f9..bd30449b5b 100644 --- a/webapp/channels/src/components/user_settings/plugin/index.tsx +++ b/webapp/channels/src/components/user_settings/plugin/index.tsx @@ -4,6 +4,8 @@ import React from 'react'; import {useIntl} from 'react-intl'; +import PluggableErrorBoundary from 'plugins/pluggable/error_boundary'; + import type {PluginConfiguration} from 'types/plugins/user_settings'; import PluginAction from './plugin_action'; @@ -45,17 +47,35 @@ const PluginTab = ({
- {settings.sections.map( - (v) => - ( + {settings.sections.map((v) => { + let sectionEl; + if ('component' in v) { + const CustomComponent = v.component; + sectionEl = ( + + + + ); + } else { + sectionEl = ( + ); + } + + return ( + + {sectionEl}
- ), + + ); + }, )}
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 9d645a5ae6..62d806411a 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 @@ -28,6 +28,7 @@ const OPTION_1_TEXT = 'Option 1'; const OPTION_2_TEXT = 'Option 2'; const OPTION_3_TEXT = 'Option 3'; const SAVE_TEXT = 'Save'; +const CUSTOM_INPUT_TEXT = 'Custom input'; function getBaseProps(): Props { return { @@ -58,6 +59,17 @@ function getBaseProps(): Props { }; } +function CustomSetting() { + return (
{CUSTOM_INPUT_TEXT}
); +} + +function CustomSettingThrows() { + const throwError = () => { + throw new Error('component error'); + }; + return (
{throwError()}
); +} + describe('plugin setting', () => { it('default is properly set', () => { const props = getBaseProps(); @@ -174,4 +186,39 @@ describe('plugin setting', () => { expect(props.updateSection).toHaveBeenCalledWith(''); expect(mockSavePreferences).not.toHaveBeenCalled(); }); + + it('custom setting component', () => { + const props = getBaseProps(); + props.section.settings = [{ + name: 'custom_input', + type: 'custom', + component: CustomSetting, + }]; + renderWithContext(); + expect(screen.queryByText(CUSTOM_INPUT_TEXT)).not.toBeInTheDocument(); + props.activeSection = props.section.title; + renderWithContext(); + expect(screen.queryByText(CUSTOM_INPUT_TEXT)).toBeInTheDocument(); + }); + + it('custom setting component throws', () => { + const consoleError = console.error; + console.error = jest.fn(); + + const props = getBaseProps(); + props.section.settings = [{ + name: 'custom_input', + type: 'custom', + component: CustomSettingThrows, + }]; + props.activeSection = props.section.title; + + renderWithContext(); + expect(screen.queryByText(CUSTOM_INPUT_TEXT)).not.toBeInTheDocument(); + expect(screen.queryByText('An error occurred in the pluginId plugin.')).toBeInTheDocument(); + expect(screen.queryByText('Refresh?')).toBeInTheDocument(); + expect(console.error).toHaveBeenCalled(); + + console.error = consoleError; + }); }); 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 cb3f8a2247..8ccb7edffe 100644 --- a/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx +++ b/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx @@ -11,6 +11,7 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import SettingItemMax from 'components/setting_item_max'; import SettingItemMin from 'components/setting_item_min'; +import PluggableErrorBoundary from 'plugins/pluggable/error_boundary'; import {getPluginPreferenceKey} from 'utils/plugins/preferences'; import type {PluginConfigurationSection} from 'types/plugins/user_settings'; @@ -87,6 +88,17 @@ const PluginSetting = ({ informChange={onSettingChanged} pluginId={pluginId} />); + } else if (setting.type === 'custom') { + const CustomComponent = setting.component; + const inputEl = ( + + + + ); + inputs.push(inputEl); } } @@ -101,7 +113,6 @@ const PluginSetting = ({ inputs={inputs} submit={updateSetting} updateSection={updateSection} - /> ); } diff --git a/webapp/channels/src/components/user_settings/plugin/radio.tsx b/webapp/channels/src/components/user_settings/plugin/radio.tsx index e323d945ab..9ea89605c8 100644 --- a/webapp/channels/src/components/user_settings/plugin/radio.tsx +++ b/webapp/channels/src/components/user_settings/plugin/radio.tsx @@ -10,13 +10,13 @@ import Markdown from 'components/markdown'; import {getPluginPreferenceKey} from 'utils/plugins/preferences'; -import type {PluginConfigurationSetting} from 'types/plugins/user_settings'; +import type {PluginConfigurationRadioSetting} from 'types/plugins/user_settings'; import type {GlobalState} from 'types/store'; import RadioOption from './radio_option'; type Props = { - setting: PluginConfigurationSetting; + setting: PluginConfigurationRadioSetting; pluginId: string; informChange: (name: string, value: string) => void; } diff --git a/webapp/channels/src/plugins/export.js b/webapp/channels/src/plugins/export.js index e651addf0a..1ce7e0fc5e 100644 --- a/webapp/channels/src/plugins/export.js +++ b/webapp/channels/src/plugins/export.js @@ -15,6 +15,7 @@ import PostMessagePreview from 'components/post_view/post_message_preview'; import StartTrialFormModal from 'components/start_trial_form_modal'; import ThreadViewer from 'components/threading/thread_viewer'; import Timestamp from 'components/timestamp'; +import UserSettingsModal from 'components/user_settings/modal'; import BotTag from 'components/widgets/tag/bot_tag'; import Avatar from 'components/widgets/users/avatar'; @@ -65,6 +66,11 @@ window.WebappUtils = { modals: {openModal, ModalIdentifiers}, notificationSounds: {ring: NotificationSounds.ring, stopRing: NotificationSounds.stopRing}, sendDesktopNotificationToMe: notifyMe, + openUserSettings: (dialogProps) => openModal({ + modalId: ModalIdentifiers.USER_SETTINGS, + dialogType: UserSettingsModal, + dialogProps, + }), }; Object.defineProperty(window.WebappUtils, 'browserHistory', { get: () => getHistory(), diff --git a/webapp/channels/src/types/plugins/user_settings.ts b/webapp/channels/src/types/plugins/user_settings.ts index 6461aaf429..8c0cd8f4a2 100644 --- a/webapp/channels/src/types/plugins/user_settings.ts +++ b/webapp/channels/src/types/plugins/user_settings.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type React from 'react'; + export type PluginConfiguration = { /** Plugin ID */ @@ -14,7 +16,7 @@ export type PluginConfiguration = { /** Action that will appear at the beginning of the plugin settings tab */ action?: PluginConfigurationAction; - sections: PluginConfigurationSection[]; + sections: Array; } export type PluginConfigurationAction = { @@ -51,6 +53,15 @@ export type PluginConfigurationSection = { onSubmit?: (changes: {[name: string]: string}) => void; } +export type PluginConfigurationCustomSection = { + + /** The title of the section. All titles must be different. */ + title: string; + + /** A React component used to render the custom section. */ + component: React.ComponentType; +} + export type BasePluginConfigurationSetting = { /** Name of the setting. This will be the name used to store in the preferences. */ @@ -74,6 +85,15 @@ export type PluginConfigurationRadioSetting = BasePluginConfigurationSetting & { options: PluginConfigurationRadioSettingOption[]; } +export type PluginCustomSettingComponent = React.ComponentType<{informChange: (name: string, value: string) => void}>; + +export type PluginConfigurationCustomSetting = BasePluginConfigurationSetting & { + type: 'custom'; + + /** A React component used to render the custom setting. */ + component: PluginCustomSettingComponent; +} + export type PluginConfigurationRadioSettingOption = { /** The value to store in the preferences */ @@ -86,4 +106,4 @@ export type PluginConfigurationRadioSettingOption = { helpText?: string; } -export type PluginConfigurationSetting = PluginConfigurationRadioSetting +export type PluginConfigurationSetting = PluginConfigurationRadioSetting | PluginConfigurationCustomSetting diff --git a/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts b/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.tsx similarity index 59% rename from webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts rename to webapp/channels/src/utils/plugins/plugin_setting_extraction.test.tsx index a80871f0ea..50f9d8486d 100644 --- a/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts +++ b/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.tsx @@ -1,7 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {PluginConfiguration} from 'types/plugins/user_settings'; +import React from 'react'; + +import type {PluginConfiguration, PluginConfigurationRadioSetting, PluginConfigurationSection} from 'types/plugins/user_settings'; import {extractPluginConfiguration} from './plugin_setting_extraction'; @@ -81,15 +83,80 @@ function getFullExample(): PluginConfiguration { icon: 'some icon', }; } + +function CustomSection() { + return ( +
{'Custom Section'}
+ ); +} + +function CustomInput() { + return ( + {'Custom Input'} + ); +} + +function getCustomExample(): PluginConfiguration { + return { + id: '', + uiName: 'some name', + sections: [ + { + title: 'Section', + settings: [ + { + name: 'radioA', + options: [ + { + text: 'Enabled', + value: 'on', + }, + { + text: 'Disabled', + value: 'off', + }, + ], + type: 'radio', + default: 'off', + }, + ], + }, + { + title: 'Section with custom setting', + settings: [ + { + name: 'custom_input', + type: 'custom', + component: CustomInput, + }, + ], + }, + { + title: 'Custom section', + component: CustomSection, + }, + ], + }; +} + describe('plugin setting extraction', () => { + beforeAll(() => { + console.warn = jest.fn(); + }); + it('happy path', () => { const config = getFullExample(); const res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); + expect(console.warn).not.toBeCalled(); expect(res?.sections).toHaveLength(2); - expect(res?.sections[0].settings).toHaveLength(2); - expect(res?.sections[1].settings).toHaveLength(1); - expect(res?.sections[0].settings[0].options).toHaveLength(2); + + const sections = res?.sections as PluginConfigurationSection[]; + expect(sections[0].settings).toHaveLength(2); + expect(sections[1].settings).toHaveLength(1); + + const setting = sections[0].settings[0] as PluginConfigurationRadioSetting; + expect(setting.options).toHaveLength(2); }); it('id gets overridden', () => { @@ -122,14 +189,18 @@ describe('plugin setting extraction', () => { 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); + + const sections = res?.sections as PluginConfigurationSection[]; + const configSections = config.sections as PluginConfigurationSection[]; + + expect(sections[0].disabled).toBe(configSections[0].disabled); + expect(sections[0].title).toBe(configSections[0].title); + expect(sections[0].onSubmit).toBe(configSections[0].onSubmit); + expect(sections[0].settings).toHaveLength(configSections[0].settings.length); + expect(sections[1].disabled).toBe(configSections[1].disabled); + expect(sections[1].title).toBe(configSections[1].title); + expect(sections[1].onSubmit).toBe(configSections[1].onSubmit); + expect(sections[1].settings).toHaveLength(configSections[1].settings.length); }); it('reject configs without name', () => { @@ -146,6 +217,7 @@ describe('plugin setting extraction', () => { it('filter out sections without a title', () => { const config: any = getFullExample(); config.sections[0].title = ''; + let res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); expect(res?.sections).toHaveLength(1); @@ -161,12 +233,14 @@ describe('plugin setting extraction', () => { config.sections[0].settings[0].type = ''; let res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings).toHaveLength(1); + + const sections = res?.sections as PluginConfigurationSection[]; + expect(sections[0].settings).toHaveLength(1); delete config.sections[0].settings[0].type; res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings).toHaveLength(1); + expect(sections[0].settings).toHaveLength(1); }); it('filter out settings without a name', () => { @@ -174,12 +248,16 @@ describe('plugin setting extraction', () => { config.sections[0].settings[0].name = ''; let res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings).toHaveLength(1); + + let sections = res?.sections as PluginConfigurationSection[]; + expect(sections[0].settings).toHaveLength(1); delete config.sections[0].settings[0].name; res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings).toHaveLength(1); + + sections = res?.sections as PluginConfigurationSection[]; + expect(sections[0].settings).toHaveLength(1); }); it('filter out settings without a default value', () => { @@ -187,12 +265,16 @@ describe('plugin setting extraction', () => { config.sections[0].settings[0].default = ''; let res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings).toHaveLength(1); + + let sections = res?.sections as PluginConfigurationSection[]; + expect(sections[0].settings).toHaveLength(1); delete config.sections[0].settings[0].default; res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings).toHaveLength(1); + + sections = res?.sections as PluginConfigurationSection[]; + expect(sections[0].settings).toHaveLength(1); }); it('filter out radio options without a text', () => { @@ -200,12 +282,18 @@ describe('plugin setting extraction', () => { config.sections[0].settings[0].options[0].text = ''; let res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings[0].options).toHaveLength(1); + + let sections = res?.sections as PluginConfigurationSection[]; + let settings = sections[0].settings as PluginConfigurationRadioSetting[]; + expect(settings[0].options).toHaveLength(1); delete config.sections[0].settings[0].options[0].text; res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings[0].options).toHaveLength(1); + + sections = res?.sections as PluginConfigurationSection[]; + settings = sections[0].settings as PluginConfigurationRadioSetting[]; + expect(settings[0].options).toHaveLength(1); }); it('filter out radio options without a value', () => { @@ -213,12 +301,18 @@ describe('plugin setting extraction', () => { config.sections[0].settings[0].options[0].value = ''; let res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings[0].options).toHaveLength(1); + + let sections = res?.sections as PluginConfigurationSection[]; + let settings = sections[0].settings as PluginConfigurationRadioSetting[]; + expect(settings[0].options).toHaveLength(1); delete config.sections[0].settings[0].options[0].value; res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings[0].options).toHaveLength(1); + + sections = res?.sections as PluginConfigurationSection[]; + settings = sections[0].settings as PluginConfigurationRadioSetting[]; + expect(settings[0].options).toHaveLength(1); }); it('reject configs without valid sections', () => { @@ -242,8 +336,13 @@ describe('plugin setting extraction', () => { it('filter out sections without valid settings', () => { const config = getFullExample(); - config.sections[0].settings[0].options = []; - config.sections[0].settings[1].options = []; + if ('settings' in config.sections[0] && 'options' in config.sections[0].settings[0]) { + config.sections[0].settings[0].options = []; + } + + if ('settings' in config.sections[0] && 'options' in config.sections[0].settings[1]) { + config.sections[0].settings[1].options = []; + } const res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); expect(res?.sections).toHaveLength(1); @@ -251,19 +350,23 @@ describe('plugin setting extraction', () => { it('filter out invalid settings', () => { const config = getFullExample(); - config.sections[0].settings[0].options = []; + if ('settings' in config.sections[0] && 'options' in config.sections[0].settings[0]) { + config.sections[0].settings[0].options = []; + } const res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings).toHaveLength(1); + expect((res?.sections[0] as PluginConfigurationSection).settings).toHaveLength(1); }); it('filter out radio settings without valid options', () => { const config = getFullExample(); - config.sections[0].settings[0].options[0].value = ''; - config.sections[0].settings[0].options[1].value = ''; + if ('settings' in config.sections[0] && 'options' in config.sections[0].settings[0]) { + config.sections[0].settings[0].options[0].value = ''; + config.sections[0].settings[0].options[1].value = ''; + } const res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings).toHaveLength(1); + expect((res?.sections[0] as PluginConfigurationSection).settings).toHaveLength(1); }); it('filter out ill defined action', () => { @@ -332,6 +435,60 @@ describe('plugin setting extraction', () => { config.sections[0].settings[0].type = 'newType'; const res = extractPluginConfiguration(config, 'PluginId'); expect(res).toBeTruthy(); - expect(res?.sections[0].settings).toHaveLength(1); + expect((res?.sections[0] as PluginConfigurationSection).settings).toHaveLength(1); + }); + + describe('custom components', () => { + it('valid', () => { + const config = getCustomExample(); + const res = extractPluginConfiguration(config, 'PluginId'); + expect(res).toBeTruthy(); + expect(res?.sections).toHaveLength(3); + expect(console.warn).not.toBeCalled(); + }); + + it('missing setting component', () => { + const config: any = getCustomExample(); + delete config.sections[1].settings[0].component; + const res = extractPluginConfiguration(config, 'PluginId'); + expect(res).toBeTruthy(); + expect(res?.sections).toHaveLength(2); + expect(console.warn).toBeCalled(); + expect(res?.sections[0].title).toEqual('Section'); + expect(res?.sections[1].title).toEqual('Custom section'); + }); + + it('missing section component', () => { + const config: any = getCustomExample(); + delete config.sections[2].component; + const res = extractPluginConfiguration(config, 'PluginId'); + expect(res).toBeTruthy(); + expect(res?.sections).toHaveLength(2); + expect(console.warn).toBeCalled(); + expect(res?.sections[0].title).toEqual('Section'); + expect(res?.sections[1].title).toEqual('Section with custom setting'); + }); + + it('invalid setting component', () => { + const config: any = getCustomExample(); + config.sections[1].settings[0].component = (
); // Not a component but an element + const res = extractPluginConfiguration(config, 'PluginId'); + expect(res).toBeTruthy(); + expect(res?.sections).toHaveLength(2); + expect(console.warn).toBeCalled(); + expect(res?.sections[0].title).toEqual('Section'); + expect(res?.sections[1].title).toEqual('Custom section'); + }); + + it('invalid section component', () => { + const config: any = getCustomExample(); + config.sections[2].component = (
); // Not a component but an element + const res = extractPluginConfiguration(config, 'PluginId'); + expect(res).toBeTruthy(); + expect(res?.sections).toHaveLength(2); + expect(console.warn).toBeCalled(); + expect(res?.sections[0].title).toEqual('Section'); + expect(res?.sections[1].title).toEqual('Section with custom setting'); + }); }); }); diff --git a/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx b/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx index f7c336679b..2294bf0cf2 100644 --- a/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx +++ b/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx @@ -1,7 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {BasePluginConfigurationSetting, PluginConfiguration, PluginConfigurationAction, PluginConfigurationRadioSetting, PluginConfigurationRadioSettingOption, PluginConfigurationSection} from 'types/plugins/user_settings'; +import React from 'react'; + +import type { + BasePluginConfigurationSetting, + PluginConfiguration, + PluginConfigurationAction, + PluginConfigurationRadioSetting, + PluginConfigurationRadioSettingOption, + PluginConfigurationSection, + PluginConfigurationCustomSetting, + PluginCustomSettingComponent, +} from 'types/plugins/user_settings'; export function extractPluginConfiguration(pluginConfiguration: unknown, pluginId: string) { if (!pluginConfiguration) { @@ -47,9 +58,12 @@ export function extractPluginConfiguration(pluginConfiguration: unknown, pluginI }; for (const section of pluginConfiguration.sections) { - const validSections = extractPluginConfigurationSection(section); + const validSections = extractPluginConfigurationSection(section, pluginId); if (validSections) { result.sections.push(validSections); + } else { + // eslint-disable-next-line no-console + console.warn(`Plugin ${pluginId} is trying to register an invalid configuration section. Contact the plugin developer to fix this issue.`); } } @@ -93,7 +107,7 @@ function extractPluginConfigurationAction(action: unknown): PluginConfigurationA }; } -function extractPluginConfigurationSection(section: unknown) { +function extractPluginConfigurationSection(section: unknown, pluginId: string) { if (!section) { return undefined; } @@ -106,6 +120,26 @@ function extractPluginConfigurationSection(section: unknown) { return undefined; } + if ('component' in section) { + if (!section.component || typeof section.component !== 'function') { + return undefined; + } + + try { + const Component = section.component; + if (!React.isValidElement(())) { + return undefined; + } + } catch { + return undefined; + } + + return { + title: section.title, + component: section.component as React.ComponentType, + }; + } + if (!('settings' in section) || !Array.isArray(section.settings)) { return undefined; } @@ -143,6 +177,9 @@ function extractPluginConfigurationSection(section: unknown) { const validSetting = extractPluginConfigurationSetting(setting); if (validSetting) { result.settings.push(validSetting); + } else { + // eslint-disable-next-line no-console + console.warn(`Plugin ${pluginId} is trying to register an invalid configuration section setting. Contact the plugin developer to fix this issue.`); } } @@ -203,11 +240,40 @@ function extractPluginConfigurationSetting(setting: unknown) { switch (setting.type) { case 'radio': return extractPluginConfigurationRadioSetting(setting, res); + case 'custom': + return extractPluginConfigurationCustomSetting(setting, res); default: return undefined; } } +function extractPluginConfigurationCustomSetting(setting: unknown, base: BasePluginConfigurationSetting) { + if (!setting || typeof setting !== 'object') { + return undefined; + } + + if (!('component' in setting) || !setting.component || typeof setting.component !== 'function') { + return undefined; + } + + try { + const Component = setting.component; + if (!React.isValidElement(())) { + return undefined; + } + } catch { + return undefined; + } + + const res: PluginConfigurationCustomSetting = { + ...base, + type: 'custom', + component: setting.component as PluginCustomSettingComponent, + }; + + return res; +} + function extractPluginConfigurationRadioSetting(setting: unknown, base: BasePluginConfigurationSetting) { if (!setting || typeof setting !== 'object') { return undefined;