From acd413ef695b00a2062b49f3a62490c440003c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Thu, 14 Dec 2023 11:30:31 +0100 Subject: [PATCH] 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 --- .../settings_sidebar.test.tsx | 122 ++++++++ .../settings_sidebar/settings_sidebar.tsx | 111 ++++--- .../components/user_settings/index.test.tsx | 54 ++++ .../src/components/user_settings/index.ts | 18 -- .../{user_settings.tsx => index.tsx} | 16 + .../components/user_settings/modal/index.ts | 3 + .../modal/user_settings_modal.test.tsx | 115 ++++++++ .../modal/user_settings_modal.tsx | 13 +- .../plugin/__snapshots__/plugin.test.tsx.snap | 86 ++++++ .../user_settings/plugin/plugin.test.tsx | 80 +++++ .../user_settings/plugin/plugin.tsx | 64 ++++ .../plugin/plugin_setting.test.tsx | 167 +++++++++++ .../user_settings/plugin/plugin_setting.tsx | 119 ++++++++ .../user_settings/plugin/radio.test.tsx | 139 +++++++++ .../components/user_settings/plugin/radio.tsx | 63 ++++ .../plugin/radio_option.test.tsx | 44 +++ .../user_settings/plugin/radio_option.tsx | 48 +++ .../setting_desktop_header.test.tsx | 27 ++ .../user_settings/setting_desktop_header.tsx | 15 + .../setting_mobile_header.test.tsx | 42 +++ .../user_settings/setting_mobile_header.tsx | 49 +++ webapp/channels/src/i18n/en.json | 2 + webapp/channels/src/plugins/registry.ts | 18 ++ .../src/reducers/plugins/index.test.ts | 119 ++++++++ webapp/channels/src/reducers/plugins/index.ts | 35 +++ .../src/sass/components/_settings-modal.scss | 5 + .../channels/src/sass/routes/_settings.scss | 14 + webapp/channels/src/selectors/plugins.test.js | 29 +- webapp/channels/src/selectors/plugins.ts | 8 + .../src/types/plugins/user_settings.ts | 68 +++++ webapp/channels/src/types/store/plugins.ts | 5 + webapp/channels/src/utils/constants.tsx | 1 + .../plugins/plugin_setting_extraction.test.ts | 278 ++++++++++++++++++ .../plugins/plugin_setting_extraction.tsx | 225 ++++++++++++++ .../src/utils/plugins/preferences.test.tsx | 11 + .../src/utils/plugins/preferences.tsx | 6 + 36 files changed, 2163 insertions(+), 56 deletions(-) create mode 100644 webapp/channels/src/components/settings_sidebar/settings_sidebar.test.tsx create mode 100644 webapp/channels/src/components/user_settings/index.test.tsx delete mode 100644 webapp/channels/src/components/user_settings/index.ts rename webapp/channels/src/components/user_settings/{user_settings.tsx => index.tsx} (84%) create mode 100644 webapp/channels/src/components/user_settings/modal/user_settings_modal.test.tsx create mode 100644 webapp/channels/src/components/user_settings/plugin/__snapshots__/plugin.test.tsx.snap create mode 100644 webapp/channels/src/components/user_settings/plugin/plugin.test.tsx create mode 100644 webapp/channels/src/components/user_settings/plugin/plugin.tsx create mode 100644 webapp/channels/src/components/user_settings/plugin/plugin_setting.test.tsx create mode 100644 webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx create mode 100644 webapp/channels/src/components/user_settings/plugin/radio.test.tsx create mode 100644 webapp/channels/src/components/user_settings/plugin/radio.tsx create mode 100644 webapp/channels/src/components/user_settings/plugin/radio_option.test.tsx create mode 100644 webapp/channels/src/components/user_settings/plugin/radio_option.tsx create mode 100644 webapp/channels/src/components/user_settings/setting_desktop_header.test.tsx create mode 100644 webapp/channels/src/components/user_settings/setting_desktop_header.tsx create mode 100644 webapp/channels/src/components/user_settings/setting_mobile_header.test.tsx create mode 100644 webapp/channels/src/components/user_settings/setting_mobile_header.tsx create mode 100644 webapp/channels/src/reducers/plugins/index.test.ts create mode 100644 webapp/channels/src/types/plugins/user_settings.ts create mode 100644 webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts create mode 100644 webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx create mode 100644 webapp/channels/src/utils/plugins/preferences.test.tsx create mode 100644 webapp/channels/src/utils/plugins/preferences.tsx diff --git a/webapp/channels/src/components/settings_sidebar/settings_sidebar.test.tsx b/webapp/channels/src/components/settings_sidebar/settings_sidebar.test.tsx new file mode 100644 index 0000000000..97e7c53f74 --- /dev/null +++ b/webapp/channels/src/components/settings_sidebar/settings_sidebar.test.tsx @@ -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; + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.queryByText(uiName1)).toBeInTheDocument(); + expect(screen.queryByText(uiName2)).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/settings_sidebar/settings_sidebar.tsx b/webapp/channels/src/components/settings_sidebar/settings_sidebar.tsx index de58dcb234..b6d7260f4d 100644 --- a/webapp/channels/src/components/settings_sidebar/settings_sidebar.tsx +++ b/webapp/channels/src/components/settings_sidebar/settings_sidebar.tsx @@ -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 { buttonRefs: Array>; @@ -57,42 +59,78 @@ export default class SettingsSidebar extends React.PureComponent { } } - 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 ( - + let icon; + if (typeof tab.icon === 'string') { + icon = ( + ); - }); + } else { + icon = ( + {tab.iconTitle} + ); + } + + return ( + + ); + } + + public render() { + const tabList = this.props.tabs.map((tab, index) => this.renderTab(tab, index)); + let pluginTabList: React.ReactNode; + if (this.props.pluginTabs?.length) { + pluginTabList = ( + <> +
+
  • + +
  • + {this.props.pluginTabs.map((tab, index) => this.renderTab(tab, index))} + + ); + } return (
    @@ -103,6 +141,7 @@ export default class SettingsSidebar extends React.PureComponent { aria-orientation='vertical' > {tabList} + {pluginTabList}
    ); diff --git a/webapp/channels/src/components/user_settings/index.test.tsx b/webapp/channels/src/components/user_settings/index.test.tsx new file mode 100644 index 0000000000..efdcf07374 --- /dev/null +++ b/webapp/channels/src/components/user_settings/index.test.tsx @@ -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; + +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(); + + expect(screen.queryAllByText(`${UINAME} Settings`)).not.toHaveLength(0); + expect(screen.queryAllByText(`${UINAME2} Settings`)).toHaveLength(0); + }); +}); diff --git a/webapp/channels/src/components/user_settings/index.ts b/webapp/channels/src/components/user_settings/index.ts deleted file mode 100644 index a1d248d6bb..0000000000 --- a/webapp/channels/src/components/user_settings/index.ts +++ /dev/null @@ -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); diff --git a/webapp/channels/src/components/user_settings/user_settings.tsx b/webapp/channels/src/components/user_settings/index.tsx similarity index 84% rename from webapp/channels/src/components/user_settings/user_settings.tsx rename to webapp/channels/src/components/user_settings/index.tsx index e650317316..8a6483bea2 100644 --- a/webapp/channels/src/components/user_settings/user_settings.tsx +++ b/webapp/channels/src/components/user_settings/index.tsx @@ -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 { @@ -100,6 +104,18 @@ export default class UserSettings extends React.PureComponent { /> ); + } else if (this.props.activeTab && this.props.pluginSettings[this.props.activeTab]) { + return ( +
    + +
    + ); } return
    ; diff --git a/webapp/channels/src/components/user_settings/modal/index.ts b/webapp/channels/src/components/user_settings/modal/index.ts index 1d00054c35..6238bef4ec 100644 --- a/webapp/channels/src/components/user_settings/modal/index.ts +++ b/webapp/channels/src/components/user_settings/modal/index.ts @@ -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), }; } diff --git a/webapp/channels/src/components/user_settings/modal/user_settings_modal.test.tsx b/webapp/channels/src/components/user_settings/modal/user_settings_modal.test.tsx new file mode 100644 index 0000000000..f874cd3a04 --- /dev/null +++ b/webapp/channels/src/components/user_settings/modal/user_settings_modal.test.tsx @@ -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; + +const baseProps: Props = { + isContentProductSettings: false, + onExited: jest.fn(), +}; + +const baseState: DeepPartial = { + 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(, baseState); +}); + +describe('tabs are properly rendered', () => { + it('plugin tabs are properly rendered', async () => { + const uiName1 = 'plugin_a'; + const uiName2 = 'plugin_b'; + const state: DeepPartial = { + plugins: { + userSettings: { + plugin_a: { + id: 'plugin_a', + sections: [], + uiName: uiName1, + }, + plugin_b: { + id: 'plugin_b', + sections: [], + uiName: uiName2, + }, + }, + }, + }; + + renderWithContext(, 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 = { + plugins: { + userSettings: { + plugin_a: { + id: 'plugin_a', + sections: [], + uiName, + }, + }, + }, + }; + + renderWithContext(, 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 = { + plugins: { + userSettings: { + plugin_a: { + id: 'plugin_a', + sections: [], + uiName, + icon, + }, + }, + }, + }; + renderWithContext(, mergeObjects(baseState, state)); + + const element = screen.queryByAltText(uiName); + expect(element).toBeInTheDocument(); + expect(element!.nodeName).toBe('IMG'); + expect(element!.getAttribute('src')).toBe(icon); + }); +}); diff --git a/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx b/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx index 68c4a37c54..54aab8106b 100644 --- a/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx +++ b/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx @@ -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 { ({ + 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 { this.customConfirmAction = customConfirmAction!; } } + pluginSettings={this.props.pluginSettings} + user={this.props.currentUser} /> diff --git a/webapp/channels/src/components/user_settings/plugin/__snapshots__/plugin.test.tsx.snap b/webapp/channels/src/components/user_settings/plugin/__snapshots__/plugin.test.tsx.snap new file mode 100644 index 0000000000..66eaa322c2 --- /dev/null +++ b/webapp/channels/src/components/user_settings/plugin/__snapshots__/plugin.test.tsx.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`plugin tab all props are properly passed to the children 1`] = ` +
    + +
    + +
    + +
    + +
    +
    +
    +
    +`; diff --git a/webapp/channels/src/components/user_settings/plugin/plugin.test.tsx b/webapp/channels/src/components/user_settings/plugin/plugin.test.tsx new file mode 100644 index 0000000000..9f524ad87c --- /dev/null +++ b/webapp/channels/src/components/user_settings/plugin/plugin.test.tsx @@ -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; + +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(); + expect(wrapper).toMatchSnapshot(); + }); + + it('setting name is properly set', () => { + renderWithContext(); + expect(screen.queryAllByText('plugin A Settings')).toHaveLength(2); + }); +}); diff --git a/webapp/channels/src/components/user_settings/plugin/plugin.tsx b/webapp/channels/src/components/user_settings/plugin/plugin.tsx new file mode 100644 index 0000000000..090954e4b6 --- /dev/null +++ b/webapp/channels/src/components/user_settings/plugin/plugin.tsx @@ -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 ( +
    + +
    + +
    + {settings.sections.map( + (v) => + ( + +
    + ), + )} +
    +
    +
    + ); +}; + +export default PluginTab; diff --git a/webapp/channels/src/components/user_settings/plugin/plugin_setting.test.tsx b/webapp/channels/src/components/user_settings/plugin/plugin_setting.test.tsx new file mode 100644 index 0000000000..be29b2234b --- /dev/null +++ b/webapp/channels/src/components/user_settings/plugin/plugin_setting.test.tsx @@ -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; + +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(); + 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 = { + entities: { + preferences: { + myPreferences: { + [prefKey]: { + category, + name: SETTING_1_NAME, + user_id: 'id', + value: '1', + }, + }, + }, + }, + }; + renderWithContext(, 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(); + 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(); + 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(); + fireEvent.click(screen.getByText(OPTION_1_TEXT)); + props.activeSection = ''; + rerender(); + props.activeSection = SECTION_TITLE; + rerender(); + + 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(); + props.activeSection = SECTION_TITLE; + rerender(); + fireEvent.click(screen.getByText(SAVE_TEXT)); + expect(props.section.onSubmit).not.toHaveBeenCalled(); + expect(props.updateSection).toHaveBeenCalledWith(''); + expect(mockSavePreferences).not.toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx b/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx new file mode 100644 index 0000000000..cfeb633790 --- /dev/null +++ b/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx @@ -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((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( + ); + } + } + + if (!inputs.length) { + return null; + } + + if (section.title === activeSection) { + return ( + + ); + } + + return ( + + ); +}; + +export default PluginSetting; diff --git a/webapp/channels/src/components/user_settings/plugin/radio.test.tsx b/webapp/channels/src/components/user_settings/plugin/radio.test.tsx new file mode 100644 index 0000000000..9450e6c02e --- /dev/null +++ b/webapp/channels/src/components/user_settings/plugin/radio.test.tsx @@ -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; + +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(); + + expect(screen.queryByText(props.setting.helpText!)).toBeInTheDocument(); + expect(screen.queryByText(props.setting.title!)).toBeInTheDocument(); + }); + + it('inform change is called', () => { + const props = getBaseProps(); + renderWithContext(); + + 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 = { + entities: { + preferences: { + myPreferences: { + [prefKey]: { + category, + name: SETTING_NAME, + user_id: 'id', + value: '1', + }, + }, + }, + }, + }; + renderWithContext(, 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 = { + entities: { + preferences: { + myPreferences: { + [prefKey]: { + category, + name: SETTING_NAME, + user_id: 'id', + value: '1', + }, + }, + }, + }, + }; + const props = getBaseProps(); + props.setting.default = '1'; + renderWithContext(, 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(); + + 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(); + }); +}); diff --git a/webapp/channels/src/components/user_settings/plugin/radio.tsx b/webapp/channels/src/components/user_settings/plugin/radio.tsx new file mode 100644 index 0000000000..e323d945ab --- /dev/null +++ b/webapp/channels/src/components/user_settings/plugin/radio.tsx @@ -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((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 ( +
    + + {setting.title || setting.name} + + {setting.options.map((option) => ( + + ))} + {setting.helpText && ( +
    + +
    + )} +
    + ); +}; + +export default RadioInput; diff --git a/webapp/channels/src/components/user_settings/plugin/radio_option.test.tsx b/webapp/channels/src/components/user_settings/plugin/radio_option.test.tsx new file mode 100644 index 0000000000..d60fdb3f5e --- /dev/null +++ b/webapp/channels/src/components/user_settings/plugin/radio_option.test.tsx @@ -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; + +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(); + + expect(screen.queryByText(props.option.text)).toBeInTheDocument(); + expect(screen.queryByText(props.option.helpText!)).toBeInTheDocument(); + }); + + it('onSelected is properly called', () => { + const props = getBaseProps(); + renderWithContext(); + + fireEvent.click(screen.getByText(props.option.text)); + + expect(props.onSelected).toHaveBeenCalledWith(props.option.value); + }); +}); diff --git a/webapp/channels/src/components/user_settings/plugin/radio_option.tsx b/webapp/channels/src/components/user_settings/plugin/radio_option.tsx new file mode 100644 index 0000000000..b2c3541a18 --- /dev/null +++ b/webapp/channels/src/components/user_settings/plugin/radio_option.tsx @@ -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 ( +
    + +
    + {option.helpText && ( + + )} +
    + ); +}; + +export default RadioOption; diff --git a/webapp/channels/src/components/user_settings/setting_desktop_header.test.tsx b/webapp/channels/src/components/user_settings/setting_desktop_header.test.tsx new file mode 100644 index 0000000000..127921a2cc --- /dev/null +++ b/webapp/channels/src/components/user_settings/setting_desktop_header.test.tsx @@ -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; + +const baseProps: Props = { + text: 'setting header', +}; + +describe('plugin tab', () => { + it('properly renders the header', () => { + renderWithContext(); + 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'); + }); +}); diff --git a/webapp/channels/src/components/user_settings/setting_desktop_header.tsx b/webapp/channels/src/components/user_settings/setting_desktop_header.tsx new file mode 100644 index 0000000000..1e672ce1d5 --- /dev/null +++ b/webapp/channels/src/components/user_settings/setting_desktop_header.tsx @@ -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) => ( +

    + {text} +

    +); + +export default SettingDesktopHeader; diff --git a/webapp/channels/src/components/user_settings/setting_mobile_header.test.tsx b/webapp/channels/src/components/user_settings/setting_mobile_header.test.tsx new file mode 100644 index 0000000000..b6e7f0441c --- /dev/null +++ b/webapp/channels/src/components/user_settings/setting_mobile_header.test.tsx @@ -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; + +const baseProps: Props = { + closeModal: jest.fn(), + collapseModal: jest.fn(), + text: 'setting header', +}; + +describe('plugin tab', () => { + it('calls closeModal on hitting close', () => { + renderWithContext(); + fireEvent.click(screen.getByText('×')); + expect(baseProps.closeModal).toHaveBeenCalled(); + }); + + it('calls collapseModal on hitting back', () => { + renderWithContext(); + fireEvent.click(screen.getByLabelText('Collapse Icon')); + expect(baseProps.collapseModal).toHaveBeenCalled(); + }); + + it('properly renders the header', () => { + renderWithContext(); + 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'); + }); +}); diff --git a/webapp/channels/src/components/user_settings/setting_mobile_header.tsx b/webapp/channels/src/components/user_settings/setting_mobile_header.tsx new file mode 100644 index 0000000000..e361bb3935 --- /dev/null +++ b/webapp/channels/src/components/user_settings/setting_mobile_header.tsx @@ -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 ( +
    + +

    +
    + +
    + {text} +

    +
    + ); +}; + +export default SettingMobileHeader; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 21f49c328c..e3020b6db3 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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", diff --git a/webapp/channels/src/plugins/registry.ts b/webapp/channels/src/plugins/registry.ts index 0c5056bb1d..af569113b0 100644 --- a/webapp/channels/src/plugins/registry.ts +++ b/webapp/channels/src/plugins/registry.ts @@ -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, + }); + }); } diff --git a/webapp/channels/src/reducers/plugins/index.test.ts b/webapp/channels/src/reducers/plugins/index.test.ts new file mode 100644 index 0000000000..bc991d0246 --- /dev/null +++ b/webapp/channels/src/reducers/plugins/index.test.ts @@ -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({}); + }); +}); diff --git a/webapp/channels/src/reducers/plugins/index.ts b/webapp/channels/src/reducers/plugins/index.ts index 9210eff1b3..04cb3e32f0 100644 --- a/webapp/channels/src/reducers/plugins/index.ts +++ b/webapp/channels/src/reducers/plugins/index.ts @@ -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, }); diff --git a/webapp/channels/src/sass/components/_settings-modal.scss b/webapp/channels/src/sass/components/_settings-modal.scss index 042e28c28a..1d006b47f4 100644 --- a/webapp/channels/src/sass/components/_settings-modal.scss +++ b/webapp/channels/src/sass/components/_settings-modal.scss @@ -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; diff --git a/webapp/channels/src/sass/routes/_settings.scss b/webapp/channels/src/sass/routes/_settings.scss index 08dab46778..5bdd7dc7c4 100644 --- a/webapp/channels/src/sass/routes/_settings.scss +++ b/webapp/channels/src/sass/routes/_settings.scss @@ -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; diff --git a/webapp/channels/src/selectors/plugins.test.js b/webapp/channels/src/selectors/plugins.test.js index 2c69d7970b..cb12245c8f 100644 --- a/webapp/channels/src/selectors/plugins.test.js +++ b/webapp/channels/src/selectors/plugins.test.js @@ -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 = []; diff --git a/webapp/channels/src/selectors/plugins.ts b/webapp/channels/src/selectors/plugins.ts index c5b9e6779c..edc2eca079 100644 --- a/webapp/channels/src/selectors/plugins.ts +++ b/webapp/channels/src/selectors/plugins.ts @@ -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, diff --git a/webapp/channels/src/types/plugins/user_settings.ts b/webapp/channels/src/types/plugins/user_settings.ts new file mode 100644 index 0000000000..6aa5e6875b --- /dev/null +++ b/webapp/channels/src/types/plugins/user_settings.ts @@ -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 diff --git a/webapp/channels/src/types/store/plugins.ts b/webapp/channels/src/types/store/plugins.ts index a84d5b49ca..d0a87fbbd3 100644 --- a/webapp/channels/src/types/store/plugins.ts +++ b/webapp/channels/src/types/store/plugins.ts @@ -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>; @@ -63,6 +64,10 @@ export type PluginsState = { siteStatsHandlers: { [pluginId: string]: PluginSiteStatsHandler; }; + + userSettings: { + [pluginId: string]: PluginConfiguration; + }; }; export type Menu = { diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index f3e4e3f3ed..a4465c8d49 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -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, diff --git a/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts b/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts new file mode 100644 index 0000000000..c058aff9dd --- /dev/null +++ b/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts @@ -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); + }); +}); diff --git a/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx b/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx new file mode 100644 index 0000000000..3d65e54948 --- /dev/null +++ b/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx @@ -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; +} diff --git a/webapp/channels/src/utils/plugins/preferences.test.tsx b/webapp/channels/src/utils/plugins/preferences.test.tsx new file mode 100644 index 0000000000..63f19c02ef --- /dev/null +++ b/webapp/channels/src/utils/plugins/preferences.test.tsx @@ -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); + }); +}); diff --git a/webapp/channels/src/utils/plugins/preferences.tsx b/webapp/channels/src/utils/plugins/preferences.tsx new file mode 100644 index 0000000000..6b509b782b --- /dev/null +++ b/webapp/channels/src/utils/plugins/preferences.tsx @@ -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); +}