mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
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:
committed by
GitHub
parent
563f51f3db
commit
acd413ef69
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
54
webapp/channels/src/components/user_settings/index.test.tsx
Normal file
54
webapp/channels/src/components/user_settings/index.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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/>;
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
119
webapp/channels/src/reducers/plugins/index.test.ts
Normal file
119
webapp/channels/src/reducers/plugins/index.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
68
webapp/channels/src/types/plugins/user_settings.ts
Normal file
68
webapp/channels/src/types/plugins/user_settings.ts
Normal 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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
225
webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx
Normal file
225
webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx
Normal 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;
|
||||
}
|
||||
11
webapp/channels/src/utils/plugins/preferences.test.tsx
Normal file
11
webapp/channels/src/utils/plugins/preferences.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
6
webapp/channels/src/utils/plugins/preferences.tsx
Normal file
6
webapp/channels/src/utils/plugins/preferences.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user