[MM-51514] Custom user settings (#27535)

* Custom user settings

* Implement error boundary, fix validation

* Extraction tests

* Tests

* Bring back refresh option for failed pluggable

* [MM-51514] Allow plugins to open user settings modal (#27742)

* Allow plugins to open user settings modal

* [MM-51514] Improvements to custom plugin user settings (#27754)

* Allow compass icon to be used

* Pass props to custom plugin settings

* Export react-select

* Revert "Export react-select"

This reverts commit 6b026c4eb789d298b721494a70e0e860a704d428.

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Claudio Costa
2024-08-13 08:49:12 +02:00
коммит произвёл GitHub
родитель 5bfc8a81d1
Коммит e5842e67a8
11 изменённых файлов: 589 добавлений и 113 удалений

Просмотреть файл

@@ -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<GlobalState> = {
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(
<UserSettingsModal
{...baseProps}
activeTab='plugin_b'
/>,
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<GlobalState> = {
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<GlobalState> = {
plugins: {
userSettings: {
plugin_a: {
id: 'plugin_a',
sections: [],
uiName,
icon,
},
},
},
};
renderWithContext(<UserSettingsModal {...baseProps}/>, 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<GlobalState> = {
plugins: {
userSettings: {
plugin_a: {
id: 'plugin_a',
sections: [],
uiName,
icon,
},
},
},
};
renderWithContext(<UserSettingsModal {...baseProps}/>, 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');
});
});

Просмотреть файл

@@ -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<Props, State> {
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<Props, State> {
};
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() {

Просмотреть файл

@@ -12,75 +12,119 @@ import PluginTab from './index';
type Props = ComponentProps<typeof PluginTab>;
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 (<div>{CUSTOM_SECTION_TEXT}</div>);
}
function CustomSectionThrows() {
const throwError = () => {
throw new Error('component error');
};
return (<div>{throwError()}</div>);
}
describe('plugin tab', () => {
it('all props are properly passed to the children', () => {
const wrapper = shallow(<PluginTab {...baseProps}/>);
const wrapper = shallow(<PluginTab {...getBaseProps()}/>);
expect(wrapper).toMatchSnapshot();
});
it('setting name is properly set', () => {
renderWithContext(<PluginTab {...baseProps}/>);
renderWithContext(<PluginTab {...getBaseProps()}/>);
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(<PluginTab {...props}/>);
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(<PluginTab {...props}/>);
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;
});
});

Просмотреть файл

@@ -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 = ({
<SettingDesktopHeader text={headerText}/>
<PluginAction action={settings.action}/>
<div className='divider-dark first'/>
{settings.sections.map(
(v) =>
(<React.Fragment key={v.title}>
{settings.sections.map((v) => {
let sectionEl;
if ('component' in v) {
const CustomComponent = v.component;
sectionEl = (
<PluggableErrorBoundary
pluginId={settings.id}
>
<CustomComponent/>
</PluggableErrorBoundary>
);
} else {
sectionEl = (
<PluginSetting
pluginId={settings.id}
activeSection={activeSection}
section={v}
updateSection={updateSection}
/>
);
}
return (
<React.Fragment key={v.title}>
{sectionEl}
<div className='divider-light'/>
</React.Fragment>),
</React.Fragment>
);
},
)}
<div className='divider-dark'/>
</div>

Просмотреть файл

@@ -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 (<div>{CUSTOM_INPUT_TEXT}</div>);
}
function CustomSettingThrows() {
const throwError = () => {
throw new Error('component error');
};
return (<div>{throwError()}</div>);
}
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(<PluginSetting {...props}/>);
expect(screen.queryByText(CUSTOM_INPUT_TEXT)).not.toBeInTheDocument();
props.activeSection = props.section.title;
renderWithContext(<PluginSetting {...props}/>);
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(<PluginSetting {...props}/>);
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;
});
});

Просмотреть файл

@@ -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 = (
<PluggableErrorBoundary
key={setting.name}
pluginId={pluginId}
>
<CustomComponent informChange={onSettingChanged}/>
</PluggableErrorBoundary>
);
inputs.push(inputEl);
}
}
@@ -101,7 +113,6 @@ const PluginSetting = ({
inputs={inputs}
submit={updateSetting}
updateSection={updateSection}
/>
);
}

Просмотреть файл

@@ -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;
}

Просмотреть файл

@@ -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(),

Просмотреть файл

@@ -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<PluginConfigurationSection | PluginConfigurationCustomSection>;
}
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

Просмотреть файл

@@ -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 (
<div>{'Custom Section'}</div>
);
}
function CustomInput() {
return (
<input>{'Custom Input'}</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 = (<div/>); // 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 = (<div/>); // 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');
});
});
});

Просмотреть файл

@@ -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((<Component/>))) {
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((<Component/>))) {
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;