Add plugin user settings (#25517)

* Add plugin user settings

* Feedback and UI improvements

* Extract the plugin configuration instead of just validating it

* Fix lint

* Fix lint

* Divide between settings and sections

* i18n-extract

* Adjust icon location

* Add tests

* Improve documentation

* Force plugin id

* Fix test

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Daniel Espino García
2023-12-14 11:30:31 +01:00
committed by GitHub
parent 563f51f3db
commit acd413ef69
36 changed files with 2163 additions and 56 deletions

View File

@@ -0,0 +1,122 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import type {ComponentProps} from 'react';
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import SettingsSidebar from './settings_sidebar';
type Props = ComponentProps<typeof SettingsSidebar>;
const baseProps: Props = {
isMobileView: false,
tabs: [],
updateTab: jest.fn(),
pluginTabs: [],
};
describe('properly use the correct icon', () => {
it('icon as a string', () => {
const iconTitle = 'Icon title';
const icon = 'icon';
const props: Props = {
...baseProps,
tabs: [{
icon,
iconTitle,
name: 'tab',
uiName: 'Tab UI Name',
}],
};
renderWithContext(<SettingsSidebar {...props}/>);
const element = screen.queryByTitle(iconTitle);
expect(element).toBeInTheDocument();
expect(element!.nodeName).toBe('I');
expect(element!.className).toBe(icon);
});
it('icon as an image', () => {
const iconTitle = 'Icon title';
const url = 'icon_url';
const props: Props = {
...baseProps,
pluginTabs: [{
icon: {url},
iconTitle,
name: 'tab',
uiName: 'Tab UI Name',
}],
};
renderWithContext(<SettingsSidebar {...props}/>);
const element = screen.queryByAltText(iconTitle);
expect(element).toBeInTheDocument();
expect(element!.nodeName).toBe('IMG');
expect(element!.getAttribute('src')).toBe(url);
});
});
describe('show PLUGIN PREFERENCES only when plugin tabs are added', () => {
it('not show when there are no plugin tabs', () => {
const props: Props = {
...baseProps,
tabs: [{
icon: 'icon',
iconTitle: 'title',
name: 'tab',
uiName: 'Tab UI Name',
}],
};
renderWithContext(<SettingsSidebar {...props}/>);
expect(screen.queryByText('PLUGIN PREFERENCES')).not.toBeInTheDocument();
});
it('show when there are plugin tabs', () => {
const props: Props = {
...baseProps,
pluginTabs: [{
icon: 'icon',
iconTitle: 'title',
name: 'tab',
uiName: 'Tab UI Name',
}],
};
renderWithContext(<SettingsSidebar {...props}/>);
expect(screen.queryByText('PLUGIN PREFERENCES')).toBeInTheDocument();
});
});
describe('tabs are properly rendered', () => {
it('plugin tabs are properly rendered', () => {
const uiName1 = 'Tab UI Name 1';
const uiName2 = 'Tab UI Name 2';
const props: Props = {
...baseProps,
pluginTabs: [
{
icon: 'icon1',
iconTitle: 'title1',
name: 'tab1',
uiName: uiName1,
},
{
icon: 'icon2',
iconTitle: 'title2',
name: 'tab2',
uiName: uiName2,
},
],
};
renderWithContext(<SettingsSidebar {...props}/>);
expect(screen.queryByText(uiName1)).toBeInTheDocument();
expect(screen.queryByText(uiName2)).toBeInTheDocument();
});
});

View File

@@ -3,6 +3,7 @@
import React from 'react';
import type {RefObject} from 'react';
import {FormattedMessage} from 'react-intl';
import Constants from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
@@ -10,7 +11,7 @@ import * as UserAgent from 'utils/user_agent';
import {a11yFocus} from 'utils/utils';
export type Tab = {
icon: string;
icon: string | {url: string};
iconTitle: string;
name: string;
uiName: string;
@@ -19,9 +20,10 @@ export type Tab = {
export type Props = {
activeTab?: string;
tabs: Tab[];
pluginTabs?: Tab[];
updateTab: (name: string) => void;
isMobileView: boolean;
}
};
export default class SettingsSidebar extends React.PureComponent<Props> {
buttonRefs: Array<RefObject<HTMLButtonElement>>;
@@ -57,42 +59,78 @@ export default class SettingsSidebar extends React.PureComponent<Props> {
}
}
public render() {
const tabList = this.props.tabs.map((tab, index) => {
const key = `${tab.name}_li`;
const isActive = this.props.activeTab === tab.name;
let className = '';
if (isActive) {
className = 'active';
}
private renderTab(tab: Tab, index: number) {
const key = `${tab.name}_li`;
const isActive = this.props.activeTab === tab.name;
let className = '';
if (isActive) {
className = 'active';
}
return (
<li
id={`${tab.name}Li`}
key={key}
className={className}
role='presentation'
>
<button
ref={this.buttonRefs[index]}
id={`${tab.name}Button`}
className='cursor--pointer style--none'
onClick={this.handleClick.bind(null, tab)}
onKeyUp={this.handleKeyUp.bind(null, index)}
aria-label={tab.uiName.toLowerCase()}
role='tab'
aria-selected={isActive}
tabIndex={!isActive && !this.props.isMobileView ? -1 : 0}
>
<i
className={tab.icon}
title={tab.iconTitle}
/>
{tab.uiName}
</button>
</li>
let icon;
if (typeof tab.icon === 'string') {
icon = (
<i
className={tab.icon}
title={tab.iconTitle}
/>
);
});
} else {
icon = (
<img
src={tab.icon.url}
alt={tab.iconTitle}
className='icon'
/>
);
}
return (
<li
id={`${tab.name}Li`}
key={key}
className={className}
role='presentation'
>
<button
ref={this.buttonRefs[index]}
id={`${tab.name}Button`}
className='cursor--pointer style--none'
onClick={this.handleClick.bind(null, tab)}
onKeyUp={this.handleKeyUp.bind(null, index)}
aria-label={tab.uiName.toLowerCase()}
role='tab'
aria-selected={isActive}
tabIndex={!isActive && !this.props.isMobileView ? -1 : 0}
>
{icon}
{tab.uiName}
</button>
</li>
);
}
public render() {
const tabList = this.props.tabs.map((tab, index) => this.renderTab(tab, index));
let pluginTabList: React.ReactNode;
if (this.props.pluginTabs?.length) {
pluginTabList = (
<>
<hr/>
<li
key={'plugin preferences heading'}
role='heading'
className={'header'}
>
<FormattedMessage
id={'userSettingsModal.pluginPreferences.header'}
defaultMessage={'PLUGIN PREFERENCES'}
/>
</li>
{this.props.pluginTabs.map((tab, index) => this.renderTab(tab, index))}
</>
);
}
return (
<div>
@@ -103,6 +141,7 @@ export default class SettingsSidebar extends React.PureComponent<Props> {
aria-orientation='vertical'
>
{tabList}
{pluginTabList}
</ul>
</div>
);

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import type {ComponentProps} from 'react';
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import UserSettings from '.';
type Props = ComponentProps<typeof UserSettings>;
const PLUGIN_ID = 'pluginId';
const UINAME = 'plugin name';
const UINAME2 = 'other plugin';
function getBaseProps(): Props {
return {
user: TestHelper.getUserMock(),
activeTab: '',
activeSection: '',
closeModal: jest.fn(),
collapseModal: jest.fn(),
pluginSettings: {
[PLUGIN_ID]: {
id: PLUGIN_ID,
sections: [],
uiName: UINAME,
},
otherPlugin: {
id: 'otherPlugin',
sections: [],
uiName: 'other plugin',
},
},
setEnforceFocus: jest.fn(),
setRequireConfirm: jest.fn(),
updateSection: jest.fn(),
updateTab: jest.fn(),
};
}
describe('plugin tabs', () => {
it('render the correct plugin tab', () => {
const props = getBaseProps();
props.activeTab = PLUGIN_ID;
renderWithContext(<UserSettings {...props}/>);
expect(screen.queryAllByText(`${UINAME} Settings`)).not.toHaveLength(0);
expect(screen.queryAllByText(`${UINAME2} Settings`)).toHaveLength(0);
});
});

View File

@@ -1,18 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import type {GlobalState} from '@mattermost/types/store';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import UserSettings from './user_settings';
function mapStateToProps(state: GlobalState) {
return {
user: getCurrentUser(state),
};
}
export default connect(mapStateToProps)(UserSettings);

View File

@@ -5,10 +5,13 @@ import React from 'react';
import type {UserProfile} from '@mattermost/types/users';
import type {PluginConfiguration} from 'types/plugins/user_settings';
import AdvancedTab from './advanced';
import DisplayTab from './display';
import GeneralTab from './general';
import NotificationsTab from './notifications';
import PluginTab from './plugin/plugin';
import SecurityTab from './security';
import SidebarTab from './sidebar';
@@ -22,6 +25,7 @@ export type Props = {
collapseModal: () => void;
setEnforceFocus: () => void;
setRequireConfirm: () => void;
pluginSettings: {[tabName: string]: PluginConfiguration};
};
export default class UserSettings extends React.PureComponent<Props> {
@@ -100,6 +104,18 @@ export default class UserSettings extends React.PureComponent<Props> {
/>
</div>
);
} else if (this.props.activeTab && this.props.pluginSettings[this.props.activeTab]) {
return (
<div>
<PluginTab
activeSection={this.props.activeSection}
updateSection={this.props.updateSection}
closeModal={this.props.closeModal}
collapseModal={this.props.collapseModal}
settings={this.props.pluginSettings[this.props.activeTab]}
/>
</div>
);
}
return <div/>;

View File

@@ -10,6 +10,8 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import type {Action} from 'mattermost-redux/types/actions';
import {getPluginUserSettings} from 'selectors/plugins';
import type {GlobalState} from 'types/store';
import UserSettingsModal from './user_settings_modal';
@@ -25,6 +27,7 @@ function mapStateToProps(state: GlobalState) {
currentUser: getCurrentUser(state),
sendEmailNotifications,
requireEmailVerification,
pluginSettings: getPluginUserSettings(state),
};
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import type {ComponentProps} from 'react';
import React from 'react';
import type {DeepPartial} from '@mattermost/types/utilities';
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import UserSettingsModal from './index';
type Props = ComponentProps<typeof UserSettingsModal>;
const baseProps: Props = {
isContentProductSettings: false,
onExited: jest.fn(),
};
const baseState: DeepPartial<GlobalState> = {
entities: {
users: {
currentUserId: 'id',
profiles: {
id: TestHelper.getUserMock({id: 'id'}),
},
},
},
};
describe('do first render to avoid other testing issues', () => {
// For some reason, the first time we render, the modal does not
// completly renders. This makes it so further tests go properly
// through.
renderWithContext(<UserSettingsModal {...baseProps}/>, baseState);
});
describe('tabs are properly rendered', () => {
it('plugin tabs are properly rendered', async () => {
const uiName1 = 'plugin_a';
const uiName2 = 'plugin_b';
const state: DeepPartial<GlobalState> = {
plugins: {
userSettings: {
plugin_a: {
id: 'plugin_a',
sections: [],
uiName: uiName1,
},
plugin_b: {
id: 'plugin_b',
sections: [],
uiName: uiName2,
},
},
},
};
renderWithContext(<UserSettingsModal {...baseProps}/>, mergeObjects(baseState, state));
expect(screen.queryByText(uiName1)).toBeInTheDocument();
expect(screen.queryByText(uiName2)).toBeInTheDocument();
});
});
describe('plugin tabs use the correct icon', () => {
it('use power plug when no icon', () => {
const uiName = 'plugin_a';
const state: DeepPartial<GlobalState> = {
plugins: {
userSettings: {
plugin_a: {
id: 'plugin_a',
sections: [],
uiName,
},
},
},
};
renderWithContext(<UserSettingsModal {...baseProps}/>, mergeObjects(baseState, state));
const element = screen.queryByTitle(uiName);
expect(element).toBeInTheDocument();
expect(element!.nodeName).toBe('I');
expect(element?.className).toBe('icon-power-plug-outline');
});
it('use image when icon provided', () => {
const uiName = 'plugin_a';
const icon = 'icon_url';
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);
});
});

View File

@@ -24,8 +24,10 @@ import * as Keyboard from 'utils/keyboard';
import * as NotificationSounds from 'utils/notification_sounds';
import * as Utils from 'utils/utils';
import type {PluginConfiguration} from 'types/plugins/user_settings';
const UserSettings = React.lazy(() => import(/* webpackPrefetch: true */ 'components/user_settings'));
const SettingsSidebar = React.lazy(() => import(/* webpackPrefetch: true */ '../../settings_sidebar'));
const SettingsSidebar = React.lazy(() => import(/* webpackPrefetch: true */ 'components/settings_sidebar'));
const holders = defineMessages({
profile: {
@@ -83,6 +85,7 @@ export type Props = {
};
}>;
};
pluginSettings: {[pluginId: string]: PluginConfiguration};
}
type State = {
@@ -324,6 +327,12 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
<Provider store={store}>
<SettingsSidebar
tabs={tabs}
pluginTabs={Object.values(this.props.pluginSettings).map((v) => ({
icon: v.icon ? {url: v.icon} : 'icon-power-plug-outline',
iconTitle: v.uiName,
name: v.id,
uiName: v.uiName,
}))}
activeTab={this.state.active_tab}
updateTab={this.updateTab}
/>
@@ -347,6 +356,8 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
this.customConfirmAction = customConfirmAction!;
}
}
pluginSettings={this.props.pluginSettings}
user={this.props.currentUser}
/>
</Provider>
</React.Suspense>

View File

@@ -0,0 +1,86 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`plugin tab all props are properly passed to the children 1`] = `
<div>
<SettingMobileHeader
closeModal={[MockFunction]}
collapseModal={[MockFunction]}
text="plugin A Settings"
/>
<div
className="user-settings"
>
<SettingDesktopHeader
text="plugin A Settings"
/>
<div
className="divider-dark first"
/>
<PluginSetting
activeSection=""
pluginId="pluginA"
section={
Object {
"onSubmit": [MockFunction],
"settings": Array [
Object {
"default": "0",
"name": "0",
"options": Array [
Object {
"text": "Option 0",
"value": "0",
},
Object {
"text": "Option 1",
"value": "1",
},
],
"type": "radio",
},
],
"title": "section 1",
}
}
updateSection={[MockFunction]}
/>
<div
className="divider-light"
/>
<PluginSetting
activeSection=""
pluginId="pluginA"
section={
Object {
"onSubmit": [MockFunction],
"settings": Array [
Object {
"default": "1",
"name": "1",
"options": Array [
Object {
"text": "Option 0",
"value": "0",
},
Object {
"text": "Option 1",
"value": "1",
},
],
"type": "radio",
},
],
"title": "section 2",
}
}
updateSection={[MockFunction]}
/>
<div
className="divider-light"
/>
<div
className="divider-dark"
/>
</div>
</div>
`;

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import {shallow} from 'enzyme';
import type {ComponentProps} from 'react';
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import PluginTab from './plugin';
type Props = ComponentProps<typeof PluginTab>;
const baseProps: Props = {
activeSection: '',
closeModal: jest.fn(),
collapseModal: jest.fn(),
settings: {
id: 'pluginA',
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(),
};
describe('plugin tab', () => {
it('all props are properly passed to the children', () => {
const wrapper = shallow(<PluginTab {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
});
it('setting name is properly set', () => {
renderWithContext(<PluginTab {...baseProps}/>);
expect(screen.queryAllByText('plugin A Settings')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import type {PluginConfiguration} from 'types/plugins/user_settings';
import PluginSetting from './plugin_setting';
import SettingDesktopHeader from '../setting_desktop_header';
import SettingMobileHeader from '../setting_mobile_header';
type Props = {
updateSection: (section: string) => void;
activeSection: string;
closeModal: () => void;
collapseModal: () => void;
settings: PluginConfiguration;
}
const PluginTab = ({
activeSection,
closeModal,
collapseModal,
settings,
updateSection,
}: Props) => {
const intl = useIntl();
const headerText = intl.formatMessage(
{id: 'user.settings.plugins.title', defaultMessage: '{pluginName} Settings'},
{pluginName: settings.uiName},
);
return (
<div>
<SettingMobileHeader
closeModal={closeModal}
collapseModal={collapseModal}
text={headerText}
/>
<div className='user-settings'>
<SettingDesktopHeader text={headerText}/>
<div className='divider-dark first'/>
{settings.sections.map(
(v) =>
(<React.Fragment key={v.title}>
<PluginSetting
pluginId={settings.id}
activeSection={activeSection}
section={v}
updateSection={updateSection}
/>
<div className='divider-light'/>
</React.Fragment>),
)}
<div className='divider-dark'/>
</div>
</div>
);
};
export default PluginTab;

View File

@@ -0,0 +1,167 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, screen} from '@testing-library/react';
import type {ComponentProps} from 'react';
import React from 'react';
import type {DeepPartial} from '@mattermost/types/utilities';
import * as preferencesActions from 'mattermost-redux/actions/preferences';
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import {renderWithContext} from 'tests/react_testing_utils';
import {getPluginPreferenceKey} from 'utils/plugins/preferences';
import type {GlobalState} from 'types/store';
import PluginSetting from './plugin_setting';
type Props = ComponentProps<typeof PluginSetting>;
const SECTION_TITLE = 'some title';
const PLUGIN_ID = 'pluginId';
const SETTING_1_NAME = 'setting_name';
const SETTING_2_NAME = 'setting_name_2';
const OPTION_0_TEXT = 'Option 0';
const OPTION_1_TEXT = 'Option 1';
const OPTION_2_TEXT = 'Option 2';
const OPTION_3_TEXT = 'Option 3';
const SAVE_TEXT = 'Save';
function getBaseProps(): Props {
return {
activeSection: '',
pluginId: PLUGIN_ID,
section: {
settings: [
{
default: '0',
name: SETTING_1_NAME,
options: [
{
text: OPTION_0_TEXT,
value: '0',
},
{
text: OPTION_1_TEXT,
value: '1',
},
],
type: 'radio',
},
],
title: SECTION_TITLE,
onSubmit: jest.fn(),
},
updateSection: jest.fn(),
};
}
describe('plugin setting', () => {
it('default is properly set', () => {
const props = getBaseProps();
props.section.settings[0].default = '1';
renderWithContext(<PluginSetting {...props}/>);
expect(screen.queryByText(OPTION_1_TEXT)).toBeInTheDocument();
});
it('properly take the current value from the preferences', () => {
const category = getPluginPreferenceKey(PLUGIN_ID);
const prefKey = getPreferenceKey(category, SETTING_1_NAME);
const state: DeepPartial<GlobalState> = {
entities: {
preferences: {
myPreferences: {
[prefKey]: {
category,
name: SETTING_1_NAME,
user_id: 'id',
value: '1',
},
},
},
},
};
renderWithContext(<PluginSetting {...getBaseProps()}/>, state);
expect(screen.queryByText(OPTION_1_TEXT)).toBeInTheDocument();
});
it('onSubmit gets called', () => {
const mockSavePreferences = jest.spyOn(preferencesActions, 'savePreferences');
const props = getBaseProps();
props.activeSection = SECTION_TITLE;
props.section.settings.push({
default: '2',
name: SETTING_2_NAME,
options: [
{
value: '2',
text: OPTION_2_TEXT,
},
{
value: '3',
text: OPTION_3_TEXT,
},
],
type: 'radio',
});
renderWithContext(<PluginSetting {...props}/>);
fireEvent.click(screen.getByText(OPTION_1_TEXT));
fireEvent.click(screen.getByText(OPTION_3_TEXT));
fireEvent.click(screen.getByText(SAVE_TEXT));
expect(props.section.onSubmit).toHaveBeenCalledWith({[SETTING_1_NAME]: '1', [SETTING_2_NAME]: '3'});
expect(props.updateSection).toHaveBeenCalledWith('');
expect(mockSavePreferences).toHaveBeenCalledWith('', [
{
user_id: '',
category: getPluginPreferenceKey(PLUGIN_ID),
name: SETTING_1_NAME,
value: '1',
},
{
user_id: '',
category: getPluginPreferenceKey(PLUGIN_ID),
name: SETTING_2_NAME,
value: '3',
},
]);
});
it('does not update anything if nothing has changed', () => {
const mockSavePreferences = jest.spyOn(preferencesActions, 'savePreferences');
const props = getBaseProps();
props.activeSection = SECTION_TITLE;
renderWithContext(<PluginSetting {...props}/>);
fireEvent.click(screen.getByText(SAVE_TEXT));
expect(props.section.onSubmit).not.toHaveBeenCalled();
expect(props.updateSection).toHaveBeenCalledWith('');
expect(mockSavePreferences).not.toHaveBeenCalled();
});
it('does not consider anything changed after moving back and forth between sections', () => {
const mockSavePreferences = jest.spyOn(preferencesActions, 'savePreferences');
const props = getBaseProps();
props.activeSection = SECTION_TITLE;
const {rerender} = renderWithContext(<PluginSetting {...props}/>);
fireEvent.click(screen.getByText(OPTION_1_TEXT));
props.activeSection = '';
rerender(<PluginSetting {...props}/>);
props.activeSection = SECTION_TITLE;
rerender(<PluginSetting {...props}/>);
fireEvent.click(screen.getByText(SAVE_TEXT));
expect(props.section.onSubmit).not.toHaveBeenCalled();
expect(props.updateSection).toHaveBeenCalledWith('');
expect(mockSavePreferences).not.toHaveBeenCalled();
fireEvent.click(screen.getByText(OPTION_1_TEXT));
props.activeSection = 'other section';
rerender(<PluginSetting {...props}/>);
props.activeSection = SECTION_TITLE;
rerender(<PluginSetting {...props}/>);
fireEvent.click(screen.getByText(SAVE_TEXT));
expect(props.section.onSubmit).not.toHaveBeenCalled();
expect(props.updateSection).toHaveBeenCalledWith('');
expect(mockSavePreferences).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import SettingItemMax from 'components/setting_item_max';
import SettingItemMin from 'components/setting_item_min';
import {getPluginPreferenceKey} from 'utils/plugins/preferences';
import type {PluginConfigurationSection} from 'types/plugins/user_settings';
import type {GlobalState} from 'types/store';
import RadioInput from './radio';
type Props = {
pluginId: string;
updateSection: (section: string) => void;
activeSection: string;
section: PluginConfigurationSection;
}
const PluginSetting = ({
pluginId,
section,
activeSection,
updateSection,
}: Props) => {
const dispatch = useDispatch();
const userId = useSelector(getCurrentUserId);
const preferenceMin = useSelector<GlobalState, string>((state: GlobalState) => getPreference(state, getPluginPreferenceKey(pluginId), section.settings[0].name, section.settings[0].default));
const toUpdate = useRef<{[name: string]: string}>({});
const minDescribe = useMemo(() => {
const setting = section.settings[0];
if (setting.type === 'radio') {
return setting.options.find((v) => v.value === preferenceMin)?.text;
}
return undefined;
}, [section, preferenceMin]);
const onSettingChanged = useCallback((name: string, value: string) => {
toUpdate.current[name] = value;
}, []);
const updateSetting = useCallback(async () => {
const preferences = [];
for (const key of Object.keys(toUpdate.current)) {
preferences.push({
user_id: userId,
category: getPluginPreferenceKey(pluginId),
name: key,
value: toUpdate.current[key],
});
}
if (preferences.length) {
// Save preferences does not offer any await strategy or error handling
// so I am leaving this as is for now. We probably should update save
// preferences and handle any kind of error or network delay here.
dispatch(savePreferences(userId, preferences));
section.onSubmit?.(toUpdate.current);
}
updateSection('');
}, [pluginId, dispatch, section.onSubmit]);
useEffect(() => {
if (activeSection !== section.title) {
toUpdate.current = {};
}
}, [activeSection, section.title]);
const inputs = [];
for (const setting of section.settings) {
if (setting.type === 'radio') {
inputs.push(
<RadioInput
key={setting.name}
setting={setting}
informChange={onSettingChanged}
pluginId={pluginId}
/>);
}
}
if (!inputs.length) {
return null;
}
if (section.title === activeSection) {
return (
<SettingItemMax
title={section.title}
inputs={inputs}
submit={updateSetting}
updateSection={updateSection}
/>
);
}
return (
<SettingItemMin
section={section.title}
title={section.title}
updateSection={updateSection}
describe={minDescribe}
/>
);
};
export default PluginSetting;

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen, fireEvent} from '@testing-library/react';
import type {ComponentProps} from 'react';
import React from 'react';
import type {DeepPartial} from '@mattermost/types/utilities';
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import {renderWithContext} from 'tests/react_testing_utils';
import {getPluginPreferenceKey} from 'utils/plugins/preferences';
import type {GlobalState} from 'types/store';
import RadioInput from './radio';
type Props = ComponentProps<typeof RadioInput>;
const PLUGIN_ID = 'pluginId';
const SETTING_NAME = 'setting_name';
const OPTION_0_TEXT = 'Option 0';
const OPTION_1_TEXT = 'Option 1';
function getBaseProps(): Props {
return {
informChange: jest.fn(),
pluginId: PLUGIN_ID,
setting: {
default: '0',
options: [
{
text: OPTION_0_TEXT,
value: '0',
helpText: 'Help text 0',
},
{
text: OPTION_1_TEXT,
value: '1',
helpText: 'Help text 1',
},
],
name: SETTING_NAME,
type: 'radio',
helpText: 'Some help text',
title: 'Some title',
},
};
}
describe('radio', () => {
it('all texts are displayed', () => {
const props = getBaseProps();
renderWithContext(<RadioInput {...getBaseProps()}/>);
expect(screen.queryByText(props.setting.helpText!)).toBeInTheDocument();
expect(screen.queryByText(props.setting.title!)).toBeInTheDocument();
});
it('inform change is called', () => {
const props = getBaseProps();
renderWithContext(<RadioInput {...props}/>);
fireEvent.click(screen.getByText(OPTION_1_TEXT));
expect(props.informChange).toHaveBeenCalledWith(SETTING_NAME, '1');
});
it('properly get the default from preferences', () => {
const category = getPluginPreferenceKey(PLUGIN_ID);
const prefKey = getPreferenceKey(category, SETTING_NAME);
const state: DeepPartial<GlobalState> = {
entities: {
preferences: {
myPreferences: {
[prefKey]: {
category,
name: SETTING_NAME,
user_id: 'id',
value: '1',
},
},
},
},
};
renderWithContext(<RadioInput {...getBaseProps()}/>, state);
const option0Radio = screen.getByText(OPTION_0_TEXT).children[0];
const option1Radio = screen.getByText(OPTION_1_TEXT).children[0];
expect(option0Radio.nodeName).toBe('INPUT');
expect(option1Radio.nodeName).toBe('INPUT');
expect((option0Radio as HTMLInputElement).checked).toBeFalsy();
expect((option1Radio as HTMLInputElement).checked).toBeTruthy();
});
it('properly get the default from props', () => {
const category = getPluginPreferenceKey(PLUGIN_ID);
const prefKey = getPreferenceKey(category, SETTING_NAME);
const state: DeepPartial<GlobalState> = {
entities: {
preferences: {
myPreferences: {
[prefKey]: {
category,
name: SETTING_NAME,
user_id: 'id',
value: '1',
},
},
},
},
};
const props = getBaseProps();
props.setting.default = '1';
renderWithContext(<RadioInput {...props}/>, state);
const option0Radio = screen.getByText(OPTION_0_TEXT).children[0];
const option1Radio = screen.getByText(OPTION_1_TEXT).children[0];
expect(option0Radio.nodeName).toBe('INPUT');
expect(option1Radio.nodeName).toBe('INPUT');
expect((option0Radio as HTMLInputElement).checked).toBeFalsy();
expect((option1Radio as HTMLInputElement).checked).toBeTruthy();
});
it('properly persist changes', () => {
renderWithContext(<RadioInput {...getBaseProps()}/>);
const option0Radio = screen.getByText(OPTION_0_TEXT).children[0];
const option1Radio = screen.getByText(OPTION_1_TEXT).children[0];
expect(option0Radio.nodeName).toBe('INPUT');
expect(option1Radio.nodeName).toBe('INPUT');
expect((option0Radio as HTMLInputElement).checked).toBeTruthy();
expect((option1Radio as HTMLInputElement).checked).toBeFalsy();
fireEvent.click(option1Radio);
expect((option0Radio as HTMLInputElement).checked).toBeFalsy();
expect((option1Radio as HTMLInputElement).checked).toBeTruthy();
});
});

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {useSelector} from 'react-redux';
import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
import Markdown from 'components/markdown';
import {getPluginPreferenceKey} from 'utils/plugins/preferences';
import type {PluginConfigurationSetting} from 'types/plugins/user_settings';
import type {GlobalState} from 'types/store';
import RadioOption from './radio_option';
type Props = {
setting: PluginConfigurationSetting;
pluginId: string;
informChange: (name: string, value: string) => void;
}
const RadioInput = ({
setting,
pluginId,
informChange,
}: Props) => {
const preference = useSelector<GlobalState, string>((state: GlobalState) => getPreference(state, getPluginPreferenceKey(pluginId), setting.name, setting.default));
const [selectedValue, setSelectedValue] = useState(preference);
const onSelected = useCallback((value: string) => {
setSelectedValue(value);
informChange(setting.name, value);
}, [setting.name]);
return (
<fieldset key={setting.name}>
<legend className='form-legend hidden-label'>
{setting.title || setting.name}
</legend>
{setting.options.map((option) => (
<RadioOption
key={option.value}
name={setting.name}
option={option}
selectedValue={selectedValue}
onSelected={onSelected}
/>
))}
{setting.helpText && (
<div className='mt-5'>
<Markdown
message={setting.helpText}
options={{mentionHighlight: false}}
/>
</div>
)}
</fieldset>
);
};
export default RadioInput;

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen, fireEvent} from '@testing-library/react';
import type {ComponentProps} from 'react';
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import RadioOption from './radio_option';
type Props = ComponentProps<typeof RadioOption>;
function getBaseProps(): Props {
return {
name: 'name',
onSelected: jest.fn(),
option: {
text: 'text',
value: 'value',
helpText: 'help',
},
selectedValue: 'other',
};
}
describe('radio option', () => {
it('all text are properly rendered', () => {
const props = getBaseProps();
renderWithContext(<RadioOption {...props}/>);
expect(screen.queryByText(props.option.text)).toBeInTheDocument();
expect(screen.queryByText(props.option.helpText!)).toBeInTheDocument();
});
it('onSelected is properly called', () => {
const props = getBaseProps();
renderWithContext(<RadioOption {...props}/>);
fireEvent.click(screen.getByText(props.option.text));
expect(props.onSelected).toHaveBeenCalledWith(props.option.value);
});
});

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import Markdown from 'components/markdown';
import type {PluginConfigurationRadioSettingOption} from 'types/plugins/user_settings';
type Props = {
selectedValue: string;
name: string;
option: PluginConfigurationRadioSettingOption;
onSelected: (v: string) => void;
}
const markdownOptions = {mentionHighlight: false};
const RadioOption = ({
selectedValue,
name,
option,
onSelected,
}: Props) => {
const onChange = useCallback(() => onSelected(option.value), [option.value]);
return (
<div className={'radio'}>
<label >
<input
type='radio'
name={name}
checked={selectedValue === option.value}
onChange={onChange}
/>
{option.text}
</label>
<br/>
{option.helpText && (
<Markdown
message={option.helpText}
options={markdownOptions}
/>
)}
</div>
);
};
export default RadioOption;

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import type {ComponentProps} from 'react';
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import SettingDesktopHeader from './setting_desktop_header';
type Props = ComponentProps<typeof SettingDesktopHeader>;
const baseProps: Props = {
text: 'setting header',
};
describe('plugin tab', () => {
it('properly renders the header', () => {
renderWithContext(<SettingDesktopHeader {...baseProps}/>);
const header = screen.queryByText('setting header');
expect(header).toBeInTheDocument();
// The className is important for how the modal system work
expect(header?.className).toBe('tab-header');
});
});

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
type Props = {
text: string;
}
const SettingDesktopHeader = ({text}: Props) => (
<h3 className='tab-header'>
{text}
</h3>
);
export default SettingDesktopHeader;

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen, fireEvent} from '@testing-library/react';
import type {ComponentProps} from 'react';
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import SettingMobileHeader from './setting_mobile_header';
type Props = ComponentProps<typeof SettingMobileHeader>;
const baseProps: Props = {
closeModal: jest.fn(),
collapseModal: jest.fn(),
text: 'setting header',
};
describe('plugin tab', () => {
it('calls closeModal on hitting close', () => {
renderWithContext(<SettingMobileHeader {...baseProps}/>);
fireEvent.click(screen.getByText('×'));
expect(baseProps.closeModal).toHaveBeenCalled();
});
it('calls collapseModal on hitting back', () => {
renderWithContext(<SettingMobileHeader {...baseProps}/>);
fireEvent.click(screen.getByLabelText('Collapse Icon'));
expect(baseProps.collapseModal).toHaveBeenCalled();
});
it('properly renders the header', () => {
renderWithContext(<SettingMobileHeader {...baseProps}/>);
const header = screen.queryByText('setting header');
expect(header).toBeInTheDocument();
// The className is important for how the modal system work
expect(header?.className).toBe('modal-title');
expect(header?.parentElement?.className).toBe('modal-header');
});
});

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
type Props = {
text: string;
closeModal: () => void;
collapseModal: () => void;
}
const SettingMobileHeader = ({
text,
closeModal,
collapseModal,
}: Props) => {
const intl = useIntl();
return (
<div className='modal-header'>
<button
id='closeButton'
type='button'
className='close'
data-dismiss='modal'
onClick={closeModal}
>
<span aria-hidden='true'>{'×'}</span>
</button>
<h4 className='modal-title'>
<div className='modal-back'>
<i
className='fa fa-angle-left'
aria-label={
intl.formatMessage({
id: 'generic_icons.collapse',
defaultMessage: 'Collapse Icon',
})
}
onClick={collapseModal}
/>
</div>
{text}
</h4>
</div>
);
};
export default SettingMobileHeader;

View File

@@ -5595,6 +5595,7 @@
"user.settings.notifications.threads.desktop": "Thread reply notifications",
"user.settings.notifications.threads.push": "Thread reply notifications",
"user.settings.notifications.title": "Notification Settings",
"user.settings.plugins.title": "{pluginName} Settings",
"user.settings.profile.icon": "Profile Settings Icon",
"user.settings.push_notification.allActivity": "For all activity",
"user.settings.push_notification.allActivityAway": "For all activity when away or offline",
@@ -5725,6 +5726,7 @@
"userGuideHelp.mattermostUserGuide": "Mattermost user guide",
"userGuideHelp.reportAProblem": "Report a problem",
"userGuideHelp.trainingResources": "Training resources",
"userSettingsModal.pluginPreferences.header": "PLUGIN PREFERENCES",
"version_bar.new": "A new version of Mattermost is available.",
"version_bar.refresh": "Refresh the app now",
"view_image_popover.download": "Download",

View File

@@ -1225,4 +1225,22 @@ export default class PluginRegistry {
return id;
});
// Register a schema for user settings. This will show in the user settings modals
// and all values will be stored in the preferences with cateogry pp_${pluginId} and
// the name of the setting.
//
// The settings definition can be found in /src/types/plugins/user_settings.ts
//
// Malformed settings will be filtered out.
registerUserSettings = reArg(['setting'], ({setting}) => {
const data = {
pluginId: this.id,
setting,
};
store.dispatch({
type: ActionTypes.RECEIVED_PLUGIN_USER_SETTINGS,
data,
});
});
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {UserTypes} from 'mattermost-redux/action_types';
import {ActionTypes} from 'utils/constants';
import type {PluginConfiguration} from 'types/plugins/user_settings';
import type {PluginsState} from 'types/store/plugins';
import pluginReducers from '.';
function getBaseState(): PluginsState {
return {
adminConsoleCustomComponents: {},
adminConsoleReducers: {},
components: {} as any,
plugins: {},
postCardTypes: {},
postTypes: {},
siteStatsHandlers: {},
userSettings: {},
};
}
function getDefaultSetting(): PluginConfiguration {
return {
id: 'pluginId',
uiName: 'some name',
sections: [
{
title: 'some title',
settings: [
{
name: 'setting name',
default: '0',
type: 'radio',
options: [
{
value: '0',
text: 'option 0',
},
],
},
],
},
],
};
}
describe('user settings', () => {
it('reject invalid settings', () => {
const originalLog = console.warn;
console.warn = jest.fn();
const state = getBaseState();
const setting = getDefaultSetting();
setting.uiName = '';
const nextState = pluginReducers(state, {
type: ActionTypes.RECEIVED_PLUGIN_USER_SETTINGS,
data: {
pluginId: 'pluginId',
setting,
},
});
expect(nextState.userSettings).toEqual({});
expect(console.warn).toHaveBeenCalled();
console.warn = originalLog;
});
it('add valid ids', () => {
const setting = getDefaultSetting();
const state = getBaseState();
const nextState = pluginReducers(state, {
type: ActionTypes.RECEIVED_PLUGIN_USER_SETTINGS,
data: {
pluginId: 'pluginId',
setting,
},
});
expect(nextState.userSettings).toEqual({pluginId: setting});
});
it('removing a plugin removes the setting', () => {
const state = getBaseState();
state.userSettings.pluginId = getDefaultSetting();
const nextState = pluginReducers(state, {
type: ActionTypes.REMOVED_WEBAPP_PLUGIN,
data: {
id: 'pluginId',
},
});
expect(nextState.userSettings).toEqual({});
});
it('removing a different plugin does not remove the setting', () => {
const state = getBaseState();
state.userSettings.pluginId = getDefaultSetting();
const nextState = pluginReducers(state, {
type: ActionTypes.REMOVED_WEBAPP_PLUGIN,
data: {
id: 'otherPluginId',
},
});
expect(nextState.userSettings).toEqual(state.userSettings);
});
it('on logout all settings are removed', () => {
const state = getBaseState();
state.userSettings.pluginId = getDefaultSetting();
state.userSettings.otherPluginId = {...getDefaultSetting(), id: 'otherPluginId'};
const nextState = pluginReducers(state, {
type: UserTypes.LOGOUT_SUCCESS,
});
expect(nextState.userSettings).toEqual({});
});
});

View File

@@ -11,6 +11,7 @@ import {UserTypes} from 'mattermost-redux/action_types';
import type {GenericAction} from 'mattermost-redux/types/actions';
import {ActionTypes} from 'utils/constants';
import {extractPluginConfiguration} from 'utils/plugins/plugin_setting_extraction';
import type {PluginsState, PluginComponent, AdminConsolePluginComponent, Menu} from 'types/store/plugins';
@@ -385,6 +386,36 @@ function siteStatsHandlers(state: PluginsState['siteStatsHandlers'] = {}, action
}
}
function userSettings(state: PluginsState['userSettings'] = {}, action: GenericAction) {
switch (action.type) {
case ActionTypes.RECEIVED_PLUGIN_USER_SETTINGS:
if (action.data) {
const extractedConfiguration = extractPluginConfiguration(action.data.setting, action.data.pluginId);
if (!extractedConfiguration) {
// eslint-disable-next-line no-console
console.warn(`Plugin ${action.data.pluginId} is trying to register an invalid configuration. Contact the plugin developer to fix this issue.`);
return state;
}
const nextState = {...state};
nextState[action.data.pluginId] = extractedConfiguration;
return nextState;
}
return state;
case ActionTypes.REMOVED_WEBAPP_PLUGIN:
if (action.data) {
const nextState = {...state};
delete nextState[action.data.id];
return nextState;
}
return state;
case UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
export default combineReducers({
// object where every key is a plugin id and values are webapp plugin manifests
@@ -413,4 +444,8 @@ export default combineReducers({
// objects where every key is a plugin id and the value is a promise to fetch stats from
// a plugin to render on system console
siteStatsHandlers,
// objects where every key is a plugin id and the value is configuration schema to show in
// the user settings modal
userSettings,
});

View File

@@ -11,6 +11,11 @@
> li {
margin-bottom: 8px;
&.header {
color: rgba(var(--center-channel-color-rgb), 0.75);
font-weight: 600;
}
button {
height: 32px;
padding: 0 12px;

View File

@@ -425,6 +425,13 @@
.setting-list {
padding: 0;
list-style-type: none;
.radio {
p {
padding-top: 5px;
padding-left: 20px;
}
}
}
.setting-box__item {
@@ -510,6 +517,13 @@
white-space: nowrap;
}
img {
&.icon {
height: 18px;
vertical-align: top;
}
}
.icon {
position: relative;
top: 1px;

View File

@@ -1,9 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getChannelHeaderMenuPluginComponents} from 'selectors/plugins';
import {getChannelHeaderMenuPluginComponents, getPluginUserSettings} from 'selectors/plugins';
describe('selectors/plugins', () => {
describe('getPluginUserSettings', () => {
it('has no settings', () => {
const state = {
plugins: {},
};
const settings = getPluginUserSettings(state);
expect(settings).toEqual({});
});
it('has settings', () => {
const stateSettings = {
pluginId: {
id: 'pluginId',
},
pluginId2: {
id: 'pluginId2',
},
};
const state = {
plugins: {
userSettings: stateSettings,
},
};
const settings = getPluginUserSettings(state);
expect(settings).toEqual(stateSettings);
});
});
describe('getChannelHeaderMenuPluginComponents', () => {
test('no channel header components found', () => {
const expectedComponents = [];

View File

@@ -12,6 +12,14 @@ import {createShallowSelector} from 'mattermost-redux/utils/helpers';
import type {GlobalState} from 'types/store';
import type {FileDropdownPluginComponent, PluginComponent} from 'types/store/plugins';
export const getPluginUserSettings = createSelector(
'getPluginUserSettings',
(state: GlobalState) => state.plugins.userSettings,
(settings) => {
return settings || {};
},
);
export const getFilesDropdownPluginMenuItems = createSelector(
'getFilesDropdownPluginMenuItems',
(state: GlobalState) => state.plugins.components.FilesDropdown,

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type PluginConfiguration = {
/** Plugin ID */
id: string;
/** Name of the plugin to show in the UI. We recommend to use manifest.name */
uiName: string;
/** URL to the icon to show in the UI. No icon will show the plug outline icon. */
icon?: string;
sections: PluginConfigurationSection[];
}
export type PluginConfigurationSection = {
settings: PluginConfigurationSetting[];
/** The title of the section. All titles must be different. */
title: string;
/**
* This function will be called whenever a section is saved.
*
* The configuration will be automatically saved in the user preferences,
* so use this function only in case you want to add some side effect
* to the change.
*/
onSubmit?: (changes: {[name: string]: string}) => void;
}
export type BasePluginConfigurationSetting = {
/** Name of the setting. This will be the name used to store in the preferences. */
name: string;
/** Optional header for this setting. */
title?: string;
/** Optional help text for this setting */
helpText?: string;
/** The default value to use */
default?: string;
}
export type PluginConfigurationRadioSetting = BasePluginConfigurationSetting & {
type: 'radio';
/** The default value to use */
default: string;
options: PluginConfigurationRadioSettingOption[];
}
export type PluginConfigurationRadioSettingOption = {
/** The value to store in the preferences */
value: string;
/** The text to show in the UI */
text: string;
/** Optional help text for this option */
helpText?: string;
}
export type PluginConfigurationSetting = PluginConfigurationRadioSetting

View File

@@ -15,6 +15,7 @@ import type {IDMappedObjects} from '@mattermost/types/utilities';
import type {NewPostMessageProps} from 'actions/new_post';
import type {PluginConfiguration} from 'types/plugins/user_settings';
import type {GlobalState} from 'types/store';
export type PluginSiteStatsHandler = () => Promise<Record<string, PluginAnalyticsRow>>;
@@ -63,6 +64,10 @@ export type PluginsState = {
siteStatsHandlers: {
[pluginId: string]: PluginSiteStatsHandler;
};
userSettings: {
[pluginId: string]: PluginConfiguration;
};
};
export type Menu = {

View File

@@ -234,6 +234,7 @@ export const ActionTypes = keyMirror({
REMOVED_ADMIN_CONSOLE_REDUCER: null,
RECEIVED_ADMIN_CONSOLE_CUSTOM_COMPONENT: null,
RECEIVED_PLUGIN_STATS_HANDLER: null,
RECEIVED_PLUGIN_USER_SETTINGS: null,
MODAL_OPEN: null,
MODAL_CLOSE: null,

View File

@@ -0,0 +1,278 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PluginConfiguration} from 'types/plugins/user_settings';
import {extractPluginConfiguration} from './plugin_setting_extraction';
function getFullExample(): PluginConfiguration {
return {
id: '',
sections: [
{
settings: [
{
default: '1-1',
name: '1-1 name',
options: [
{
text: '1-1-1',
value: '1-1-1 value',
helpText: '1-1-1 help text',
},
{
text: '1-1-2',
value: '1-1-2 value',
},
],
type: 'radio',
helpText: '1-1 help text',
title: '1-1 title',
},
{
default: '1-2',
name: '1-2 name',
options: [
{
text: '1-2-1',
value: '1-2-1 value',
helpText: '1-2-1 help text',
},
],
type: 'radio',
},
],
title: 'title 1',
onSubmit: () => 1,
},
{
settings: [
{
default: '2-1',
name: '2-1 name',
options: [
{
text: '2-1-1',
value: '2-1-1 value',
helpText: '2-1-1 help text',
},
{
text: '2-1-2',
value: '2-1-2 value',
},
],
type: 'radio',
helpText: '2-1 help text',
title: '2-1 title',
},
],
title: 'title 2',
onSubmit: () => 2,
},
],
uiName: 'some name',
icon: 'some icon',
};
}
describe('plugin setting extraction', () => {
it('happy path', () => {
const config = getFullExample();
const res = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
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);
});
it('id gets overridden', () => {
const config: any = getFullExample();
const pluginId = 'PluginId';
config.id = 'otherId';
let res = extractPluginConfiguration(config, pluginId);
expect(res).toBeTruthy();
expect(res!.id).toBe(pluginId);
delete config.id;
res = extractPluginConfiguration(config, pluginId);
expect(res).toBeTruthy();
expect(res!.id).toBe(pluginId);
});
it('reject configs without name', () => {
const config: any = getFullExample();
config.uiName = '';
let res = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeFalsy();
delete config.uiName;
res = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeFalsy();
});
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);
delete config.sections[0].title;
res = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res?.sections).toHaveLength(1);
});
it('filter out settings without a type', () => {
const config: any = getFullExample();
config.sections[0].settings[0].type = '';
let res = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res?.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);
});
it('filter out settings without a name', () => {
const config: any = getFullExample();
config.sections[0].settings[0].name = '';
let res = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res?.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);
});
it('filter out settings without a default value', () => {
const config: any = getFullExample();
config.sections[0].settings[0].default = '';
let res = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res?.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);
});
it('filter out radio options without a text', () => {
const config: any = getFullExample();
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);
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);
});
it('filter out radio options without a value', () => {
const config: any = getFullExample();
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);
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);
});
it('reject configs without valid sections', () => {
const config = getFullExample();
config.sections = [{
settings: [],
title: 'foo',
}];
const res: any = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeFalsy();
});
it('filter out invalid sections', () => {
const config = getFullExample();
config.sections.push({settings: [], title: 'foo'});
const res = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res?.sections).toHaveLength(2);
});
it('filter out sections without valid settings', () => {
const config = getFullExample();
config.sections[0].settings[0].options = [];
config.sections[0].settings[1].options = [];
const res = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res?.sections).toHaveLength(1);
});
it('filter out invalid settings', () => {
const config = getFullExample();
config.sections[0].settings[0].options = [];
const res = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res?.sections[0].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 = '';
const res = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res?.sections[0].settings).toHaveLength(1);
});
it('(future proof) filter out extra config arguments', () => {
const config: any = getFullExample();
config.futureProperty = 'hello';
const res: any = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res.futureProperty).toBeFalsy();
});
it('(future proof) filter out extra section arguments', () => {
const config: any = getFullExample();
config.sections[0].futureProperty = 'hello';
const res: any = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res.sections).toHaveLength(2);
expect(res.sections[0].futureProperty).toBeFalsy();
});
it('(future proof) filter out extra setting arguments', () => {
const config: any = getFullExample();
config.sections[0].settings[0].futureProperty = 'hello';
const res: any = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res.sections[0].settings).toHaveLength(2);
expect(res.sections[0].settings[0].futureProperty).toBeFalsy();
});
it('(future proof) filter out extra option arguments', () => {
const config: any = getFullExample();
config.sections[0].settings[0].options[0].futureProperty = 'hello';
const res: any = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res.sections[0].settings[0].options).toHaveLength(2);
expect(res.sections[0].settings[0].options[0].futureProperty).toBeFalsy();
});
it('(future proof) filter out unknown settings', () => {
const config: any = getFullExample();
config.sections[0].settings[0].type = 'newType';
const res = extractPluginConfiguration(config, 'PluginId');
expect(res).toBeTruthy();
expect(res?.sections[0].settings).toHaveLength(1);
});
});

View File

@@ -0,0 +1,225 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {BasePluginConfigurationSetting, PluginConfiguration, PluginConfigurationRadioSetting, PluginConfigurationRadioSettingOption, PluginConfigurationSection} from 'types/plugins/user_settings';
export function extractPluginConfiguration(pluginConfiguration: unknown, pluginId: string) {
if (!pluginConfiguration) {
return undefined;
}
if (typeof pluginConfiguration !== 'object') {
return undefined;
}
if (!('uiName' in pluginConfiguration) || !pluginConfiguration.uiName || typeof pluginConfiguration.uiName !== 'string') {
return undefined;
}
let icon;
if ('icon' in pluginConfiguration && pluginConfiguration.icon) {
if (typeof pluginConfiguration.icon === 'string') {
icon = pluginConfiguration.icon;
} else {
return undefined;
}
}
if (!('sections' in pluginConfiguration) || !Array.isArray(pluginConfiguration.sections)) {
return undefined;
}
if (!pluginConfiguration.sections.length) {
return undefined;
}
const result: PluginConfiguration = {
id: pluginId,
icon,
sections: [],
uiName: pluginConfiguration.uiName,
};
for (const section of pluginConfiguration.sections) {
const validSections = extractPluginConfigurationSection(section);
if (validSections) {
result.sections.push(validSections);
}
}
if (!result.sections.length) {
return undefined;
}
return result;
}
function extractPluginConfigurationSection(section: unknown) {
if (!section) {
return undefined;
}
if (typeof section !== 'object') {
return undefined;
}
if (!('title' in section) || !section.title || typeof section.title !== 'string') {
return undefined;
}
if (!('settings' in section) || !Array.isArray(section.settings)) {
return undefined;
}
if (!section.settings.length) {
return undefined;
}
let onSubmit;
if ('onSubmit' in section && section.onSubmit) {
if (typeof section.onSubmit === 'function') {
onSubmit = section.onSubmit as PluginConfigurationSection['onSubmit'];
} else {
return undefined;
}
}
const result: PluginConfigurationSection = {
settings: [],
title: section.title,
onSubmit,
};
for (const setting of section.settings) {
const validSetting = extractPluginConfigurationSetting(setting);
if (validSetting) {
result.settings.push(validSetting);
}
}
if (!result.settings.length) {
return undefined;
}
return result;
}
function extractPluginConfigurationSetting(setting: unknown) {
if (!setting || typeof setting !== 'object') {
return undefined;
}
if (!('name' in setting) || !setting.name || typeof setting.name !== 'string') {
return undefined;
}
let title;
if (('title' in setting) && setting.title) {
if (typeof setting.title === 'string') {
title = setting.title;
} else {
return undefined;
}
}
let helpText;
if ('helpText' in setting && setting.helpText) {
if (typeof setting.helpText === 'string') {
helpText = setting.helpText;
} else {
return undefined;
}
}
let defaultValue;
if ('default' in setting && setting.default) {
if (typeof setting.default === 'string') {
defaultValue = setting.default;
} else {
return undefined;
}
}
if (!('type' in setting) || !setting.type || typeof setting.type !== 'string') {
return undefined;
}
const res: BasePluginConfigurationSetting = {
default: defaultValue,
name: setting.name,
title,
helpText,
};
switch (setting.type) {
case 'radio':
return extractPluginConfigurationRadioSetting(setting, res);
default:
return undefined;
}
}
function extractPluginConfigurationRadioSetting(setting: unknown, base: BasePluginConfigurationSetting) {
if (!setting || typeof setting !== 'object') {
return undefined;
}
if (!('default' in setting) || !setting.default || typeof setting.default !== 'string') {
return undefined;
}
if (!('options' in setting) || !Array.isArray(setting.options)) {
return undefined;
}
const res: PluginConfigurationRadioSetting = {
...base,
type: 'radio',
default: setting.default,
options: [],
};
for (const option of setting.options) {
const isValid = extractValidRadioOption(option);
if (isValid) {
res.options.push(isValid);
}
}
if (!res.options.length) {
return undefined;
}
return res;
}
function extractValidRadioOption(option: unknown) {
if (!option || typeof option !== 'object') {
return undefined;
}
if (!('value' in option) || !option.value || typeof option.value !== 'string') {
return undefined;
}
if (!('text' in option) || !option.text || typeof option.text !== 'string') {
return undefined;
}
let helpText;
if ('helpText' in option && option.helpText) {
if (typeof option.helpText === 'string') {
helpText = option.helpText;
} else {
return undefined;
}
}
const res: PluginConfigurationRadioSettingOption = {
value: option.value,
text: option.text,
helpText,
};
return res;
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getPluginPreferenceKey} from './preferences';
describe('getPluginPreferenceKey', () => {
it('Does not go over 32 characters', () => {
const key = getPluginPreferenceKey('1234567890abcdefghjklmnopqrstuvwxyz');
expect(key).toHaveLength(32);
});
});

View File

@@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function getPluginPreferenceKey(pluginId: string) {
return `pp_${pluginId}`.slice(0, 32);
}