[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>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
5bfc8a81d1
Коммит
e5842e67a8
@@ -99,6 +99,58 @@ describe('tabs are properly rendered', () => {
|
|||||||
expect(screen.queryByText(uiName1)).toBeInTheDocument();
|
expect(screen.queryByText(uiName1)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(uiName2)).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', () => {
|
describe('plugin tabs use the correct icon', () => {
|
||||||
@@ -121,12 +173,12 @@ describe('plugin tabs use the correct icon', () => {
|
|||||||
const element = screen.queryByTitle(uiName);
|
const element = screen.queryByTitle(uiName);
|
||||||
expect(element).toBeInTheDocument();
|
expect(element).toBeInTheDocument();
|
||||||
expect(element!.nodeName).toBe('I');
|
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 uiName = 'plugin_a';
|
||||||
const icon = 'icon_url';
|
const icon = 'http://localhost:8065/plugins/com.mattermost.plugin_a/public/icon.svg';
|
||||||
const state: DeepPartial<GlobalState> = {
|
const state: DeepPartial<GlobalState> = {
|
||||||
plugins: {
|
plugins: {
|
||||||
userSettings: {
|
userSettings: {
|
||||||
@@ -146,4 +198,51 @@ describe('plugin tabs use the correct icon', () => {
|
|||||||
expect(element!.nodeName).toBe('IMG');
|
expect(element!.nodeName).toBe('IMG');
|
||||||
expect(element!.getAttribute('src')).toBe(icon);
|
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 Constants from 'utils/constants';
|
||||||
import {cmdOrCtrlPressed, isKeyPressed} from 'utils/keyboard';
|
import {cmdOrCtrlPressed, isKeyPressed} from 'utils/keyboard';
|
||||||
import {stopTryNotificationRing} from 'utils/notification_sounds';
|
import {stopTryNotificationRing} from 'utils/notification_sounds';
|
||||||
|
import {isValidUrl} from 'utils/url';
|
||||||
import {getDisplayName} from 'utils/utils';
|
import {getDisplayName} from 'utils/utils';
|
||||||
|
|
||||||
import type {PluginConfiguration} from 'types/plugins/user_settings';
|
import type {PluginConfiguration} from 'types/plugins/user_settings';
|
||||||
@@ -30,6 +31,7 @@ export type OwnProps = {
|
|||||||
adminMode?: boolean;
|
adminMode?: boolean;
|
||||||
isContentProductSettings: boolean;
|
isContentProductSettings: boolean;
|
||||||
userPreferences?: PreferencesType;
|
userPreferences?: PreferencesType;
|
||||||
|
activeTab?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Props = OwnProps & {
|
export type Props = OwnProps & {
|
||||||
@@ -64,7 +66,7 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
|
|||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
active_tab: props.isContentProductSettings ? 'notifications' : 'profile',
|
active_tab: props.activeTab ?? (props.isContentProductSettings ? 'notifications' : 'profile'),
|
||||||
active_section: '',
|
active_section: '',
|
||||||
showConfirmModal: false,
|
showConfirmModal: false,
|
||||||
enforceFocus: true,
|
enforceFocus: true,
|
||||||
@@ -299,12 +301,16 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
getPluginsSettingsTab = () => {
|
getPluginsSettingsTab = () => {
|
||||||
return Object.values(this.props.pluginSettings).map((v) => ({
|
return Object.values(this.props.pluginSettings).map((v) => {
|
||||||
name: v.id,
|
const className = v.icon ? `icon ${v.icon}` : 'icon icon-power-plug-outline';
|
||||||
uiName: v.uiName,
|
const useURL = v.icon && (isValidUrl(v.icon) || v.icon.startsWith('/'));
|
||||||
icon: v.icon ? {url: v.icon} : 'icon-power-plug-outline',
|
return {
|
||||||
iconTitle: v.uiName,
|
name: v.id,
|
||||||
}));
|
uiName: v.uiName,
|
||||||
|
icon: useURL ? {url: v.icon!} : className,
|
||||||
|
iconTitle: v.uiName,
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@@ -12,75 +12,119 @@ import PluginTab from './index';
|
|||||||
|
|
||||||
type Props = ComponentProps<typeof PluginTab>;
|
type Props = ComponentProps<typeof PluginTab>;
|
||||||
|
|
||||||
const baseProps: Props = {
|
function getBaseProps(): Props {
|
||||||
activeSection: '',
|
return {
|
||||||
closeModal: jest.fn(),
|
activeSection: '',
|
||||||
collapseModal: jest.fn(),
|
closeModal: jest.fn(),
|
||||||
settings: {
|
collapseModal: jest.fn(),
|
||||||
id: 'pluginA',
|
settings: {
|
||||||
action: {
|
id: 'pluginA',
|
||||||
text: 'actionText',
|
action: {
|
||||||
buttonText: 'buttonText',
|
text: 'actionText',
|
||||||
onClick: jest.fn(),
|
buttonText: 'buttonText',
|
||||||
title: 'actionTitle',
|
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: [
|
updateSection: jest.fn(),
|
||||||
{
|
};
|
||||||
settings: [
|
}
|
||||||
{
|
|
||||||
default: '0',
|
const CUSTOM_SECTION_TEXT = 'custom section content';
|
||||||
name: '0',
|
|
||||||
options: [
|
function CustomSection() {
|
||||||
{
|
return (<div>{CUSTOM_SECTION_TEXT}</div>);
|
||||||
text: 'Option 0',
|
}
|
||||||
value: '0',
|
|
||||||
},
|
function CustomSectionThrows() {
|
||||||
{
|
const throwError = () => {
|
||||||
text: 'Option 1',
|
throw new Error('component error');
|
||||||
value: '1',
|
};
|
||||||
},
|
return (<div>{throwError()}</div>);
|
||||||
],
|
}
|
||||||
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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('plugin tab', () => {
|
describe('plugin tab', () => {
|
||||||
it('all props are properly passed to the children', () => {
|
it('all props are properly passed to the children', () => {
|
||||||
const wrapper = shallow(<PluginTab {...baseProps}/>);
|
const wrapper = shallow(<PluginTab {...getBaseProps()}/>);
|
||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('setting name is properly set', () => {
|
it('setting name is properly set', () => {
|
||||||
renderWithContext(<PluginTab {...baseProps}/>);
|
renderWithContext(<PluginTab {...getBaseProps()}/>);
|
||||||
expect(screen.queryAllByText('plugin A Settings')).toHaveLength(2);
|
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 React from 'react';
|
||||||
import {useIntl} from 'react-intl';
|
import {useIntl} from 'react-intl';
|
||||||
|
|
||||||
|
import PluggableErrorBoundary from 'plugins/pluggable/error_boundary';
|
||||||
|
|
||||||
import type {PluginConfiguration} from 'types/plugins/user_settings';
|
import type {PluginConfiguration} from 'types/plugins/user_settings';
|
||||||
|
|
||||||
import PluginAction from './plugin_action';
|
import PluginAction from './plugin_action';
|
||||||
@@ -45,17 +47,35 @@ const PluginTab = ({
|
|||||||
<SettingDesktopHeader text={headerText}/>
|
<SettingDesktopHeader text={headerText}/>
|
||||||
<PluginAction action={settings.action}/>
|
<PluginAction action={settings.action}/>
|
||||||
<div className='divider-dark first'/>
|
<div className='divider-dark first'/>
|
||||||
{settings.sections.map(
|
{settings.sections.map((v) => {
|
||||||
(v) =>
|
let sectionEl;
|
||||||
(<React.Fragment key={v.title}>
|
if ('component' in v) {
|
||||||
|
const CustomComponent = v.component;
|
||||||
|
sectionEl = (
|
||||||
|
<PluggableErrorBoundary
|
||||||
|
pluginId={settings.id}
|
||||||
|
>
|
||||||
|
<CustomComponent/>
|
||||||
|
</PluggableErrorBoundary>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
sectionEl = (
|
||||||
<PluginSetting
|
<PluginSetting
|
||||||
pluginId={settings.id}
|
pluginId={settings.id}
|
||||||
activeSection={activeSection}
|
activeSection={activeSection}
|
||||||
section={v}
|
section={v}
|
||||||
updateSection={updateSection}
|
updateSection={updateSection}
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={v.title}>
|
||||||
|
{sectionEl}
|
||||||
<div className='divider-light'/>
|
<div className='divider-light'/>
|
||||||
</React.Fragment>),
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
<div className='divider-dark'/>
|
<div className='divider-dark'/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const OPTION_1_TEXT = 'Option 1';
|
|||||||
const OPTION_2_TEXT = 'Option 2';
|
const OPTION_2_TEXT = 'Option 2';
|
||||||
const OPTION_3_TEXT = 'Option 3';
|
const OPTION_3_TEXT = 'Option 3';
|
||||||
const SAVE_TEXT = 'Save';
|
const SAVE_TEXT = 'Save';
|
||||||
|
const CUSTOM_INPUT_TEXT = 'Custom input';
|
||||||
|
|
||||||
function getBaseProps(): Props {
|
function getBaseProps(): Props {
|
||||||
return {
|
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', () => {
|
describe('plugin setting', () => {
|
||||||
it('default is properly set', () => {
|
it('default is properly set', () => {
|
||||||
const props = getBaseProps();
|
const props = getBaseProps();
|
||||||
@@ -174,4 +186,39 @@ describe('plugin setting', () => {
|
|||||||
expect(props.updateSection).toHaveBeenCalledWith('');
|
expect(props.updateSection).toHaveBeenCalledWith('');
|
||||||
expect(mockSavePreferences).not.toHaveBeenCalled();
|
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 SettingItemMax from 'components/setting_item_max';
|
||||||
import SettingItemMin from 'components/setting_item_min';
|
import SettingItemMin from 'components/setting_item_min';
|
||||||
|
|
||||||
|
import PluggableErrorBoundary from 'plugins/pluggable/error_boundary';
|
||||||
import {getPluginPreferenceKey} from 'utils/plugins/preferences';
|
import {getPluginPreferenceKey} from 'utils/plugins/preferences';
|
||||||
|
|
||||||
import type {PluginConfigurationSection} from 'types/plugins/user_settings';
|
import type {PluginConfigurationSection} from 'types/plugins/user_settings';
|
||||||
@@ -87,6 +88,17 @@ const PluginSetting = ({
|
|||||||
informChange={onSettingChanged}
|
informChange={onSettingChanged}
|
||||||
pluginId={pluginId}
|
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}
|
inputs={inputs}
|
||||||
submit={updateSetting}
|
submit={updateSetting}
|
||||||
updateSection={updateSection}
|
updateSection={updateSection}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import Markdown from 'components/markdown';
|
|||||||
|
|
||||||
import {getPluginPreferenceKey} from 'utils/plugins/preferences';
|
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 type {GlobalState} from 'types/store';
|
||||||
|
|
||||||
import RadioOption from './radio_option';
|
import RadioOption from './radio_option';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
setting: PluginConfigurationSetting;
|
setting: PluginConfigurationRadioSetting;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
informChange: (name: string, value: string) => void;
|
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 StartTrialFormModal from 'components/start_trial_form_modal';
|
||||||
import ThreadViewer from 'components/threading/thread_viewer';
|
import ThreadViewer from 'components/threading/thread_viewer';
|
||||||
import Timestamp from 'components/timestamp';
|
import Timestamp from 'components/timestamp';
|
||||||
|
import UserSettingsModal from 'components/user_settings/modal';
|
||||||
import BotTag from 'components/widgets/tag/bot_tag';
|
import BotTag from 'components/widgets/tag/bot_tag';
|
||||||
import Avatar from 'components/widgets/users/avatar';
|
import Avatar from 'components/widgets/users/avatar';
|
||||||
|
|
||||||
@@ -65,6 +66,11 @@ window.WebappUtils = {
|
|||||||
modals: {openModal, ModalIdentifiers},
|
modals: {openModal, ModalIdentifiers},
|
||||||
notificationSounds: {ring: NotificationSounds.ring, stopRing: NotificationSounds.stopRing},
|
notificationSounds: {ring: NotificationSounds.ring, stopRing: NotificationSounds.stopRing},
|
||||||
sendDesktopNotificationToMe: notifyMe,
|
sendDesktopNotificationToMe: notifyMe,
|
||||||
|
openUserSettings: (dialogProps) => openModal({
|
||||||
|
modalId: ModalIdentifiers.USER_SETTINGS,
|
||||||
|
dialogType: UserSettingsModal,
|
||||||
|
dialogProps,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
Object.defineProperty(window.WebappUtils, 'browserHistory', {
|
Object.defineProperty(window.WebappUtils, 'browserHistory', {
|
||||||
get: () => getHistory(),
|
get: () => getHistory(),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
export type PluginConfiguration = {
|
export type PluginConfiguration = {
|
||||||
|
|
||||||
/** Plugin ID */
|
/** Plugin ID */
|
||||||
@@ -14,7 +16,7 @@ export type PluginConfiguration = {
|
|||||||
|
|
||||||
/** Action that will appear at the beginning of the plugin settings tab */
|
/** Action that will appear at the beginning of the plugin settings tab */
|
||||||
action?: PluginConfigurationAction;
|
action?: PluginConfigurationAction;
|
||||||
sections: PluginConfigurationSection[];
|
sections: Array<PluginConfigurationSection | PluginConfigurationCustomSection>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginConfigurationAction = {
|
export type PluginConfigurationAction = {
|
||||||
@@ -51,6 +53,15 @@ export type PluginConfigurationSection = {
|
|||||||
onSubmit?: (changes: {[name: string]: string}) => void;
|
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 = {
|
export type BasePluginConfigurationSetting = {
|
||||||
|
|
||||||
/** Name of the setting. This will be the name used to store in the preferences. */
|
/** 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[];
|
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 = {
|
export type PluginConfigurationRadioSettingOption = {
|
||||||
|
|
||||||
/** The value to store in the preferences */
|
/** The value to store in the preferences */
|
||||||
@@ -86,4 +106,4 @@ export type PluginConfigurationRadioSettingOption = {
|
|||||||
helpText?: string;
|
helpText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginConfigurationSetting = PluginConfigurationRadioSetting
|
export type PluginConfigurationSetting = PluginConfigurationRadioSetting | PluginConfigurationCustomSetting
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// 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';
|
import {extractPluginConfiguration} from './plugin_setting_extraction';
|
||||||
|
|
||||||
@@ -81,15 +83,80 @@ function getFullExample(): PluginConfiguration {
|
|||||||
icon: 'some icon',
|
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', () => {
|
describe('plugin setting extraction', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
console.warn = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
it('happy path', () => {
|
it('happy path', () => {
|
||||||
const config = getFullExample();
|
const config = getFullExample();
|
||||||
const res = extractPluginConfiguration(config, 'PluginId');
|
const res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
expect(res).toBeTruthy();
|
||||||
|
expect(console.warn).not.toBeCalled();
|
||||||
expect(res?.sections).toHaveLength(2);
|
expect(res?.sections).toHaveLength(2);
|
||||||
expect(res?.sections[0].settings).toHaveLength(2);
|
|
||||||
expect(res?.sections[1].settings).toHaveLength(1);
|
const sections = res?.sections as PluginConfigurationSection[];
|
||||||
expect(res?.sections[0].settings[0].options).toHaveLength(2);
|
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', () => {
|
it('id gets overridden', () => {
|
||||||
@@ -122,14 +189,18 @@ describe('plugin setting extraction', () => {
|
|||||||
const res = extractPluginConfiguration(config, 'PluginId');
|
const res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
expect(res).toBeTruthy();
|
||||||
expect(res?.sections).toHaveLength(2);
|
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);
|
const sections = res?.sections as PluginConfigurationSection[];
|
||||||
expect(res?.sections[0].onSubmit).toBe(config.sections[0].onSubmit);
|
const configSections = config.sections as PluginConfigurationSection[];
|
||||||
expect(res?.sections[0].settings).toHaveLength(config.sections[0].settings.length);
|
|
||||||
expect(res?.sections[1].disabled).toBe(config.sections[1].disabled);
|
expect(sections[0].disabled).toBe(configSections[0].disabled);
|
||||||
expect(res?.sections[1].title).toBe(config.sections[1].title);
|
expect(sections[0].title).toBe(configSections[0].title);
|
||||||
expect(res?.sections[1].onSubmit).toBe(config.sections[1].onSubmit);
|
expect(sections[0].onSubmit).toBe(configSections[0].onSubmit);
|
||||||
expect(res?.sections[1].settings).toHaveLength(config.sections[1].settings.length);
|
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', () => {
|
it('reject configs without name', () => {
|
||||||
@@ -146,6 +217,7 @@ describe('plugin setting extraction', () => {
|
|||||||
it('filter out sections without a title', () => {
|
it('filter out sections without a title', () => {
|
||||||
const config: any = getFullExample();
|
const config: any = getFullExample();
|
||||||
config.sections[0].title = '';
|
config.sections[0].title = '';
|
||||||
|
|
||||||
let res = extractPluginConfiguration(config, 'PluginId');
|
let res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
expect(res).toBeTruthy();
|
||||||
expect(res?.sections).toHaveLength(1);
|
expect(res?.sections).toHaveLength(1);
|
||||||
@@ -161,12 +233,14 @@ describe('plugin setting extraction', () => {
|
|||||||
config.sections[0].settings[0].type = '';
|
config.sections[0].settings[0].type = '';
|
||||||
let res = extractPluginConfiguration(config, 'PluginId');
|
let res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
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;
|
delete config.sections[0].settings[0].type;
|
||||||
res = extractPluginConfiguration(config, 'PluginId');
|
res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
expect(res).toBeTruthy();
|
||||||
expect(res?.sections[0].settings).toHaveLength(1);
|
expect(sections[0].settings).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filter out settings without a name', () => {
|
it('filter out settings without a name', () => {
|
||||||
@@ -174,12 +248,16 @@ describe('plugin setting extraction', () => {
|
|||||||
config.sections[0].settings[0].name = '';
|
config.sections[0].settings[0].name = '';
|
||||||
let res = extractPluginConfiguration(config, 'PluginId');
|
let res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
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;
|
delete config.sections[0].settings[0].name;
|
||||||
res = extractPluginConfiguration(config, 'PluginId');
|
res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
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', () => {
|
it('filter out settings without a default value', () => {
|
||||||
@@ -187,12 +265,16 @@ describe('plugin setting extraction', () => {
|
|||||||
config.sections[0].settings[0].default = '';
|
config.sections[0].settings[0].default = '';
|
||||||
let res = extractPluginConfiguration(config, 'PluginId');
|
let res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
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;
|
delete config.sections[0].settings[0].default;
|
||||||
res = extractPluginConfiguration(config, 'PluginId');
|
res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
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', () => {
|
it('filter out radio options without a text', () => {
|
||||||
@@ -200,12 +282,18 @@ describe('plugin setting extraction', () => {
|
|||||||
config.sections[0].settings[0].options[0].text = '';
|
config.sections[0].settings[0].options[0].text = '';
|
||||||
let res = extractPluginConfiguration(config, 'PluginId');
|
let res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
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;
|
delete config.sections[0].settings[0].options[0].text;
|
||||||
res = extractPluginConfiguration(config, 'PluginId');
|
res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
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', () => {
|
it('filter out radio options without a value', () => {
|
||||||
@@ -213,12 +301,18 @@ describe('plugin setting extraction', () => {
|
|||||||
config.sections[0].settings[0].options[0].value = '';
|
config.sections[0].settings[0].options[0].value = '';
|
||||||
let res = extractPluginConfiguration(config, 'PluginId');
|
let res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
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;
|
delete config.sections[0].settings[0].options[0].value;
|
||||||
res = extractPluginConfiguration(config, 'PluginId');
|
res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
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', () => {
|
it('reject configs without valid sections', () => {
|
||||||
@@ -242,8 +336,13 @@ describe('plugin setting extraction', () => {
|
|||||||
|
|
||||||
it('filter out sections without valid settings', () => {
|
it('filter out sections without valid settings', () => {
|
||||||
const config = getFullExample();
|
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[1].options = [];
|
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');
|
const res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
expect(res).toBeTruthy();
|
||||||
expect(res?.sections).toHaveLength(1);
|
expect(res?.sections).toHaveLength(1);
|
||||||
@@ -251,19 +350,23 @@ describe('plugin setting extraction', () => {
|
|||||||
|
|
||||||
it('filter out invalid settings', () => {
|
it('filter out invalid settings', () => {
|
||||||
const config = getFullExample();
|
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');
|
const res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
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', () => {
|
it('filter out radio settings without valid options', () => {
|
||||||
const config = getFullExample();
|
const config = getFullExample();
|
||||||
config.sections[0].settings[0].options[0].value = '';
|
if ('settings' in config.sections[0] && 'options' in config.sections[0].settings[0]) {
|
||||||
config.sections[0].settings[0].options[1].value = '';
|
config.sections[0].settings[0].options[0].value = '';
|
||||||
|
config.sections[0].settings[0].options[1].value = '';
|
||||||
|
}
|
||||||
const res = extractPluginConfiguration(config, 'PluginId');
|
const res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
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', () => {
|
it('filter out ill defined action', () => {
|
||||||
@@ -332,6 +435,60 @@ describe('plugin setting extraction', () => {
|
|||||||
config.sections[0].settings[0].type = 'newType';
|
config.sections[0].settings[0].type = 'newType';
|
||||||
const res = extractPluginConfiguration(config, 'PluginId');
|
const res = extractPluginConfiguration(config, 'PluginId');
|
||||||
expect(res).toBeTruthy();
|
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.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// 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) {
|
export function extractPluginConfiguration(pluginConfiguration: unknown, pluginId: string) {
|
||||||
if (!pluginConfiguration) {
|
if (!pluginConfiguration) {
|
||||||
@@ -47,9 +58,12 @@ export function extractPluginConfiguration(pluginConfiguration: unknown, pluginI
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const section of pluginConfiguration.sections) {
|
for (const section of pluginConfiguration.sections) {
|
||||||
const validSections = extractPluginConfigurationSection(section);
|
const validSections = extractPluginConfigurationSection(section, pluginId);
|
||||||
if (validSections) {
|
if (validSections) {
|
||||||
result.sections.push(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) {
|
if (!section) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -106,6 +120,26 @@ function extractPluginConfigurationSection(section: unknown) {
|
|||||||
return undefined;
|
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)) {
|
if (!('settings' in section) || !Array.isArray(section.settings)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -143,6 +177,9 @@ function extractPluginConfigurationSection(section: unknown) {
|
|||||||
const validSetting = extractPluginConfigurationSetting(setting);
|
const validSetting = extractPluginConfigurationSetting(setting);
|
||||||
if (validSetting) {
|
if (validSetting) {
|
||||||
result.settings.push(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) {
|
switch (setting.type) {
|
||||||
case 'radio':
|
case 'radio':
|
||||||
return extractPluginConfigurationRadioSetting(setting, res);
|
return extractPluginConfigurationRadioSetting(setting, res);
|
||||||
|
case 'custom':
|
||||||
|
return extractPluginConfigurationCustomSetting(setting, res);
|
||||||
default:
|
default:
|
||||||
return undefined;
|
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) {
|
function extractPluginConfigurationRadioSetting(setting: unknown, base: BasePluginConfigurationSetting) {
|
||||||
if (!setting || typeof setting !== 'object') {
|
if (!setting || typeof setting !== 'object') {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user