[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(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;
|
||||
|
||||
Ссылка в новой задаче
Block a user