diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx
index 774c746513..819478ee0d 100644
--- a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx
+++ b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx
@@ -22,12 +22,14 @@ import {getGroupMessageMembersCommonTeams} from 'actions/team_actions';
import {trackEvent} from 'actions/telemetry_actions';
import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field';
-import type {Actions} from 'components/convert_gm_to_channel_modal/index';
-import NoCommonTeamsError from 'components/convert_gm_to_channel_modal/no_common_teams/no_common_teams';
-import TeamSelector from 'components/convert_gm_to_channel_modal/team_selector/team_selector';
-import WarningTextSection from 'components/convert_gm_to_channel_modal/warning_text_section/warning_text_section';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
+import NoCommonTeamsError from './no_common_teams/no_common_teams';
+import TeamSelector from './team_selector/team_selector';
+import WarningTextSection from './warning_text_section/warning_text_section';
+
+import type {Actions} from './index';
+
const enum ServerErrorId {
CHANNEL_NAME_EXISTS = 'store.sql_channel.save_channel.exists.app_error',
}
diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/no_common_teams/no_common_teams.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/no_common_teams/no_common_teams.tsx
index 62e5615a2b..9d30fc20a3 100644
--- a/webapp/channels/src/components/convert_gm_to_channel_modal/no_common_teams/no_common_teams.tsx
+++ b/webapp/channels/src/components/convert_gm_to_channel_modal/no_common_teams/no_common_teams.tsx
@@ -2,29 +2,24 @@
// See LICENSE.txt for license information.
import React from 'react';
-import {FormattedMessage} from 'react-intl';
+import {useIntl} from 'react-intl';
-import 'components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss';
+import SectionNotice from 'components/section_notice';
const NoCommonTeamsError = (): JSX.Element => {
+ const intl = useIntl();
return (
-
+
);
};
diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss b/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss
deleted file mode 100644
index 3ecf2179f2..0000000000
--- a/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss
+++ /dev/null
@@ -1,35 +0,0 @@
-.warning-section {
- display: flex;
- padding: 16px;
- border: 1px solid rgba(var(--sidebar-text-active-border-rgb), 0.16);
- background: rgba(var(--sidebar-text-active-border-rgb), 0.08);
- border-radius: 4px;
- gap: 12px;
-
- &.error {
- border: 1px solid rgba(var(--dnd-indicator-rgb), 0.16);
- background: rgba(var(--dnd-indicator-rgb), 0.08);
-
- .fa.fa-exclamation-circle {
- color: rgba(var(--dnd-indicator-rgb), 1);
- }
- }
-
- .fa.fa-exclamation-circle {
- width: 24px;
- height: 24px;
- color: rgba(var(--sidebar-text-active-border-rgb), 1);
- font-size: 24px;
- }
-
- .warning-text {
- display: flex;
- flex-direction: column;
- color: var(--center-channel-color-88);
- gap: 8px;
- }
-
- .warning-header {
- font-weight: bold;
- }
-}
diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.tsx
index 7a9c8b067e..97728f3a3c 100644
--- a/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.tsx
+++ b/webapp/channels/src/components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.tsx
@@ -2,9 +2,9 @@
// See LICENSE.txt for license information.
import React from 'react';
-import {FormattedMessage, useIntl} from 'react-intl';
+import {useIntl} from 'react-intl';
-import 'components/convert_gm_to_channel_modal/warning_text_section/warning_text_section.scss';
+import SectionNotice from 'components/section_notice';
export type Props = {
channelMemberNames: string[];
@@ -21,26 +21,19 @@ const WarningTextSection = (props: Props): JSX.Element => {
}
return (
-
+
);
};
export default WarningTextSection;
diff --git a/webapp/channels/src/components/section_notice.scss b/webapp/channels/src/components/section_notice.scss
new file mode 100644
index 0000000000..0d14826d0f
--- /dev/null
+++ b/webapp/channels/src/components/section_notice.scss
@@ -0,0 +1,120 @@
+.sectionNoticeContainer {
+ position: relative;
+ border-width: 1px;
+ border-style: solid;
+ border-radius: 4px;
+
+ p {
+ margin: 0;
+ }
+
+ &.info {
+ border-color: rgba(var(--sidebar-text-active-border-rgb), 0.16);
+ background: rgba(var(--sidebar-text-active-border-rgb), 0.08);
+
+ p {
+ color: var(--center-channel-color);
+ }
+ }
+
+ &.success {
+ border-color: rgba(var(--online-indicator-rgb), 0.16);
+ background: rgba(var(--online-indicator-rgb), 0.08);
+
+ p {
+ color: var(--center-channel-color);
+ }
+ }
+
+ &.danger {
+ border-color: rgba(var(--dnd-indicator-rgb), 0.16);
+ background: rgba(var(--dnd-indicator-rgb), 0.08);
+
+ p {
+ color: var(--center-channel-color);
+ }
+ }
+
+ &.welcome {
+ border-color: rgba(var(--center-channel-color-rgb), 0.08);
+ background: rgba(var(--center-channel-color-rgb), 0.04);
+
+ p {
+ color: rgba(var(--center-channel-color-rgb), 0.72);
+ }
+ }
+
+ &.warning {
+ border-color: rgba(var(--away-indicator-rgb), 0.16);
+ background: rgba(var(--away-indicator-rgb), 0.08);
+
+ p {
+ color: var(--center-channel-color);
+ }
+ }
+}
+
+.sectionNoticeActions {
+ display: flex;
+ flex-flow: wrap;
+}
+
+.sectionNoticeClose {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+}
+
+.sectionNoticeContent {
+ display: flex;
+ flex-direction: row;
+ align-items: start;
+ padding: 16px;
+ gap: 12px;
+}
+
+.sectionNoticeIcon {
+ font-size: 20px;
+
+ &.info {
+ color: var(--sidebar-text-active-border);
+ }
+
+ &.success {
+ color: var(--online-indicator);
+ }
+
+ &.danger {
+ color: var(--dnd-indicator);
+ }
+
+ &.warning {
+ color: var(--away-indicator);
+ }
+}
+
+.sectionNoticeTitle {
+ margin: 0;
+ color: var(--center-channel-color);
+ font-family: 'Open Sans';
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 20px;
+
+ &.welcome {
+ font-family: 'Metropolis';
+ font-size: 20px;
+ line-height: 28px;
+ }
+}
+
+.sectionNoticeBody {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.sectionNoticeButton {
+ margin-top: 4px;
+ margin-bottom: 4px;
+}
diff --git a/webapp/channels/src/components/section_notice.test.tsx b/webapp/channels/src/components/section_notice.test.tsx
new file mode 100644
index 0000000000..f59bce9364
--- /dev/null
+++ b/webapp/channels/src/components/section_notice.test.tsx
@@ -0,0 +1,70 @@
+// 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 {renderWithContext} from 'tests/react_testing_utils';
+
+import SectionNotice from './section_notice';
+
+type Props = ComponentProps;
+
+function getBaseProps(): Props {
+ return {
+ text: 'some text',
+ title: 'some title',
+ primaryButton: {
+ onClick: jest.fn(),
+ text: 'primary button title',
+ },
+ secondaryButton: {
+ onClick: jest.fn(),
+ text: 'secondary button title',
+ },
+ linkButton: {
+ onClick: jest.fn(),
+ text: 'link button title',
+ },
+ isDismissable: true,
+ onDismissClick: jest.fn(),
+ type: 'info',
+ };
+}
+
+describe('PluginAction', () => {
+ it('does show the correct information', () => {
+ const props = getBaseProps();
+ renderWithContext();
+ const primaryButton = screen.getByText(props.primaryButton!.text);
+ const secondaryButton = screen.getByText(props.secondaryButton!.text);
+ const linkButton = screen.getByText(props.linkButton!.text);
+ const closeButton = screen.getByLabelText('Dismiss notice');
+
+ expect(primaryButton).toBeInTheDocument();
+ expect(secondaryButton).toBeInTheDocument();
+ expect(linkButton).toBeInTheDocument();
+ expect(closeButton).toBeInTheDocument();
+ expect(screen.queryByText(props.text)).toBeInTheDocument();
+ expect(screen.queryByText(props.title)).toBeInTheDocument();
+ fireEvent.click(primaryButton);
+ expect(props.primaryButton?.onClick).toHaveBeenCalledTimes(1);
+ fireEvent.click(secondaryButton);
+ expect(props.secondaryButton?.onClick).toHaveBeenCalledTimes(1);
+ fireEvent.click(linkButton);
+ expect(props.linkButton?.onClick).toHaveBeenCalledTimes(1);
+ fireEvent.click(closeButton);
+ expect(props.onDismissClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not show the button if no button is passed', () => {
+ const props = getBaseProps();
+ props.primaryButton = undefined;
+ props.secondaryButton = undefined;
+ props.linkButton = undefined;
+ props.isDismissable = false;
+ renderWithContext();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+});
diff --git a/webapp/channels/src/components/section_notice.tsx b/webapp/channels/src/components/section_notice.tsx
new file mode 100644
index 0000000000..27d8a05e0d
--- /dev/null
+++ b/webapp/channels/src/components/section_notice.tsx
@@ -0,0 +1,101 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import classNames from 'classnames';
+import React from 'react';
+import {useIntl} from 'react-intl';
+
+import Markdown from 'components/markdown';
+
+import './section_notice.scss';
+
+type Button = {
+ onClick: () => void;
+ text: string;
+}
+type Props = {
+ title: string;
+ text: string;
+ primaryButton?: Button;
+ secondaryButton?: Button;
+ linkButton?: Button;
+ type?: 'info' | 'success' | 'danger' | 'welcome' | 'warning';
+ isDismissable?: boolean;
+ onDismissClick?: () => void;
+};
+
+const iconByType = {
+ info: 'icon-information-outline',
+ success: 'icon-check',
+ danger: 'icon-alert-outline',
+ warning: 'icon-alert-outline',
+ welcome: undefined,
+};
+
+const SectionNotice = ({
+ title,
+ text,
+ primaryButton,
+ secondaryButton,
+ linkButton,
+ type = 'info',
+ isDismissable,
+ onDismissClick,
+}: Props) => {
+ const intl = useIntl();
+ const icon = iconByType[type];
+ const showDismiss = Boolean(isDismissable && onDismissClick);
+ const buttonClass = 'btn btn-sm sectionNoticeButton';
+ return (
+
+
+ {icon &&
}
+
+
{title}
+
+
+ {primaryButton &&
+
+ }
+ {secondaryButton &&
+
+ }
+ {linkButton &&
+
+ }
+
+
+
+
+ {showDismiss &&
+
+ }
+
+ );
+};
+
+export default SectionNotice;
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
index f874cd3a04..d27e72f946 100644
--- 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
@@ -18,7 +18,7 @@ import UserSettingsModal from './index';
type Props = ComponentProps;
const baseProps: Props = {
- isContentProductSettings: false,
+ isContentProductSettings: true,
onExited: jest.fn(),
};
@@ -39,6 +39,40 @@ describe('do first render to avoid other testing issues', () => {
renderWithContext(, baseState);
});
+describe('plugin tabs are only rendered on content product settings', () => {
+ 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)).not.toBeInTheDocument();
+ expect(screen.queryByText(uiName2)).not.toBeInTheDocument();
+ });
+});
+
describe('tabs are properly rendered', () => {
it('plugin tabs are properly rendered', async () => {
const uiName1 = 'plugin_a';
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 54aab8106b..475a44dd8b 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
@@ -280,11 +280,18 @@ class UserSettingsModal extends React.PureComponent {
return ();
}
const tabs = [];
+ let pluginTabs;
if (this.props.isContentProductSettings) {
tabs.push({name: 'notifications', uiName: formatMessage(holders.notifications), icon: 'icon icon-bell-outline', iconTitle: Utils.localizeMessage('user.settings.notifications.icon', 'Notification Settings Icon')});
tabs.push({name: 'display', uiName: formatMessage(holders.display), icon: 'icon icon-eye-outline', iconTitle: Utils.localizeMessage('user.settings.display.icon', 'Display Settings Icon')});
tabs.push({name: 'sidebar', uiName: formatMessage(holders.sidebar), icon: 'icon icon-dock-left', iconTitle: Utils.localizeMessage('user.settings.sidebar.icon', 'Sidebar Settings Icon')});
tabs.push({name: 'advanced', uiName: formatMessage(holders.advanced), icon: 'icon icon-tune', iconTitle: Utils.localizeMessage('user.settings.advance.icon', 'Advanced Settings Icon')});
+ 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,
+ }));
} else {
tabs.push({name: 'profile', uiName: formatMessage(holders.profile), icon: 'icon icon-settings-outline', iconTitle: Utils.localizeMessage('user.settings.profile.icon', 'Profile Settings Icon')});
tabs.push({name: 'security', uiName: formatMessage(holders.security), icon: 'icon icon-lock-outline', iconTitle: Utils.localizeMessage('user.settings.security.icon', 'Security Settings Icon')});
@@ -327,12 +334,7 @@ class UserSettingsModal extends React.PureComponent {
({
- icon: v.icon ? {url: v.icon} : 'icon-power-plug-outline',
- iconTitle: v.uiName,
- name: v.id,
- uiName: v.uiName,
- }))}
+ pluginTabs={pluginTabs}
activeTab={this.state.active_tab}
updateTab={this.updateTab}
/>
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
index 66eaa322c2..69107f5bef 100644
--- 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
@@ -13,6 +13,16 @@ 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
index 9f524ad87c..8394c7be46 100644
--- a/webapp/channels/src/components/user_settings/plugin/plugin.test.tsx
+++ b/webapp/channels/src/components/user_settings/plugin/plugin.test.tsx
@@ -18,6 +18,12 @@ const baseProps: Props = {
collapseModal: jest.fn(),
settings: {
id: 'pluginA',
+ action: {
+ text: 'actionText',
+ buttonText: 'buttonText',
+ onClick: jest.fn(),
+ title: 'actionTitle',
+ },
sections: [
{
settings: [
diff --git a/webapp/channels/src/components/user_settings/plugin/plugin.tsx b/webapp/channels/src/components/user_settings/plugin/plugin.tsx
index 090954e4b6..c1dbb5e6c7 100644
--- a/webapp/channels/src/components/user_settings/plugin/plugin.tsx
+++ b/webapp/channels/src/components/user_settings/plugin/plugin.tsx
@@ -6,6 +6,7 @@ import {useIntl} from 'react-intl';
import type {PluginConfiguration} from 'types/plugins/user_settings';
+import PluginAction from './plugin_action';
import PluginSetting from './plugin_setting';
import SettingDesktopHeader from '../setting_desktop_header';
@@ -42,6 +43,7 @@ const PluginTab = ({
/>
+
{settings.sections.map(
(v) =>
diff --git a/webapp/channels/src/components/user_settings/plugin/plugin_action.scss b/webapp/channels/src/components/user_settings/plugin/plugin_action.scss
new file mode 100644
index 0000000000..e781904ff4
--- /dev/null
+++ b/webapp/channels/src/components/user_settings/plugin/plugin_action.scss
@@ -0,0 +1,3 @@
+.pluginActionContainer {
+ margin-bottom: 20px;
+}
diff --git a/webapp/channels/src/components/user_settings/plugin/plugin_action.test.tsx b/webapp/channels/src/components/user_settings/plugin/plugin_action.test.tsx
new file mode 100644
index 0000000000..0ef5103534
--- /dev/null
+++ b/webapp/channels/src/components/user_settings/plugin/plugin_action.test.tsx
@@ -0,0 +1,41 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {fireEvent, render, screen} from '@testing-library/react';
+import type {ComponentProps} from 'react';
+import React from 'react';
+
+import {renderWithContext} from 'tests/react_testing_utils';
+
+import PluginAction from './plugin_action';
+
+type Props = ComponentProps;
+
+function getBaseProps(): Props {
+ return {
+ action: {
+ title: 'some title',
+ text: 'some text',
+ buttonText: 'button text',
+ onClick: jest.fn(),
+ },
+ };
+}
+
+describe('PluginAction', () => {
+ it('does not show when no action is provided', () => {
+ const {container} = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('does show the correct information', () => {
+ const props = getBaseProps();
+ renderWithContext();
+ const button = screen.getByText(props.action!.buttonText);
+ expect(button).toBeInTheDocument();
+ expect(screen.queryByText(props.action!.text)).toBeInTheDocument();
+ expect(screen.queryByText(props.action!.title)).toBeInTheDocument();
+ fireEvent.click(button);
+ expect(props.action?.onClick).toHaveBeenCalled();
+ });
+});
diff --git a/webapp/channels/src/components/user_settings/plugin/plugin_action.tsx b/webapp/channels/src/components/user_settings/plugin/plugin_action.tsx
new file mode 100644
index 0000000000..f04b22815d
--- /dev/null
+++ b/webapp/channels/src/components/user_settings/plugin/plugin_action.tsx
@@ -0,0 +1,45 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {ComponentProps} from 'react';
+import React, {useMemo} from 'react';
+
+import SectionNotice from 'components/section_notice';
+
+import type {PluginConfigurationAction} from 'types/plugins/user_settings';
+
+import './plugin_action.scss';
+
+type Props = {
+ action?: PluginConfigurationAction;
+};
+
+const PluginAction = ({
+ action,
+}: Props) => {
+ const props = useMemo>(() => {
+ return action ? {
+ text: action.text,
+ title: action.title,
+ primaryButton: {
+ onClick: action?.onClick,
+ text: action?.buttonText,
+ },
+ } : {
+ text: '',
+ title: '',
+ };
+ }, [action]);
+
+ if (!action) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default PluginAction;
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
index be29b2234b..9d645a5ae6 100644
--- a/webapp/channels/src/components/user_settings/plugin/plugin_setting.test.tsx
+++ b/webapp/channels/src/components/user_settings/plugin/plugin_setting.test.tsx
@@ -66,6 +66,16 @@ describe('plugin setting', () => {
expect(screen.queryByText(OPTION_1_TEXT)).toBeInTheDocument();
});
+ it('isDisabled is respected', () => {
+ const props = getBaseProps();
+ props.section.disabled = true;
+ renderWithContext();
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
+ expect(screen.queryByText(SECTION_TITLE)).toBeInTheDocument();
+ fireEvent.click(screen.getByText(SECTION_TITLE));
+ expect(screen.queryByText(OPTION_1_TEXT)).not.toBeInTheDocument();
+ });
+
it('properly take the current value from the preferences', () => {
const category = getPluginPreferenceKey(PLUGIN_ID);
const prefKey = getPreferenceKey(category, SETTING_1_NAME);
diff --git a/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx b/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx
index cfeb633790..cb3f8a2247 100644
--- a/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx
+++ b/webapp/channels/src/components/user_settings/plugin/plugin_setting.tsx
@@ -112,6 +112,7 @@ const PluginSetting = ({
title={section.title}
updateSection={updateSection}
describe={minDescribe}
+ isDisabled={section.disabled}
/>
);
};
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json
index 70d34f612c..083970e0de 100644
--- a/webapp/channels/src/i18n/en.json
+++ b/webapp/channels/src/i18n/en.json
@@ -4751,6 +4751,7 @@
"search_list_option.on": "Messages on a date",
"search_list_option.phrases": "Messages with phrases",
"search_results.channel-files-header": "Recent files",
+ "sectionNotice.dismiss": "Dismiss notice",
"select_team.icon": "Select Team Icon",
"select_team.join.icon": "Join Team Icon",
"select_team.private.icon": "Private Team",
diff --git a/webapp/channels/src/types/plugins/user_settings.ts b/webapp/channels/src/types/plugins/user_settings.ts
index 6aa5e6875b..e2208c9123 100644
--- a/webapp/channels/src/types/plugins/user_settings.ts
+++ b/webapp/channels/src/types/plugins/user_settings.ts
@@ -12,6 +12,14 @@ export type PluginConfiguration = {
/** URL to the icon to show in the UI. No icon will show the plug outline icon. */
icon?: string;
sections: PluginConfigurationSection[];
+ action?: PluginConfigurationAction;
+}
+
+export type PluginConfigurationAction = {
+ title: string;
+ text: string;
+ buttonText: string;
+ onClick: () => void;
}
export type PluginConfigurationSection = {
@@ -20,6 +28,9 @@ export type PluginConfigurationSection = {
/** The title of the section. All titles must be different. */
title: string;
+ /** Whether the section is disabled. */
+ disabled?: boolean;
+
/**
* This function will be called whenever a section is saved.
*
diff --git a/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts b/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts
index c058aff9dd..a80871f0ea 100644
--- a/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts
+++ b/webapp/channels/src/utils/plugins/plugin_setting_extraction.test.ts
@@ -8,6 +8,12 @@ import {extractPluginConfiguration} from './plugin_setting_extraction';
function getFullExample(): PluginConfiguration {
return {
id: '',
+ action: {
+ buttonText: 'some button text',
+ onClick: () => 1,
+ text: 'some text',
+ title: 'some title',
+ },
sections: [
{
settings: [
@@ -68,6 +74,7 @@ function getFullExample(): PluginConfiguration {
],
title: 'title 2',
onSubmit: () => 2,
+ disabled: true,
},
],
uiName: 'some name',
@@ -99,6 +106,32 @@ describe('plugin setting extraction', () => {
expect(res!.id).toBe(pluginId);
});
+ it('action gets properly added', () => {
+ const config = getFullExample();
+ const res = extractPluginConfiguration(config, 'PluginId');
+ expect(res).toBeTruthy();
+ expect(res?.action).toBeTruthy();
+ expect(res?.action?.buttonText).toBe(config.action?.buttonText);
+ expect(res?.action?.text).toBe(config.action?.text);
+ expect(res?.action?.title).toBe(config.action?.title);
+ expect(res?.action?.onClick).toBe(config.action?.onClick);
+ });
+
+ it('sections get properly added', () => {
+ const config = getFullExample();
+ const res = extractPluginConfiguration(config, 'PluginId');
+ expect(res).toBeTruthy();
+ expect(res?.sections).toHaveLength(2);
+ expect(res?.sections[0].disabled).toBe(config.sections[0].disabled);
+ expect(res?.sections[0].title).toBe(config.sections[0].title);
+ expect(res?.sections[0].onSubmit).toBe(config.sections[0].onSubmit);
+ expect(res?.sections[0].settings).toHaveLength(config.sections[0].settings.length);
+ expect(res?.sections[1].disabled).toBe(config.sections[1].disabled);
+ expect(res?.sections[1].title).toBe(config.sections[1].title);
+ expect(res?.sections[1].onSubmit).toBe(config.sections[1].onSubmit);
+ expect(res?.sections[1].settings).toHaveLength(config.sections[1].settings.length);
+ });
+
it('reject configs without name', () => {
const config: any = getFullExample();
config.uiName = '';
@@ -233,6 +266,32 @@ describe('plugin setting extraction', () => {
expect(res?.sections[0].settings).toHaveLength(1);
});
+ it('filter out ill defined action', () => {
+ let config: any = getFullExample();
+ delete config.action?.buttonText;
+ let res = extractPluginConfiguration(config, 'PluginId');
+ expect(res).toBeTruthy();
+ expect(res?.action).toBeFalsy();
+
+ config = getFullExample();
+ delete config.action?.title;
+ res = extractPluginConfiguration(config, 'PluginId');
+ expect(res).toBeTruthy();
+ expect(res?.action).toBeFalsy();
+
+ config = getFullExample();
+ delete config.action?.text;
+ res = extractPluginConfiguration(config, 'PluginId');
+ expect(res).toBeTruthy();
+ expect(res?.action).toBeFalsy();
+
+ config = getFullExample();
+ delete config.action?.onClick;
+ res = extractPluginConfiguration(config, 'PluginId');
+ expect(res).toBeTruthy();
+ expect(res?.action).toBeFalsy();
+ });
+
it('(future proof) filter out extra config arguments', () => {
const config: any = getFullExample();
config.futureProperty = 'hello';
diff --git a/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx b/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx
index 3d65e54948..f7c336679b 100644
--- a/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx
+++ b/webapp/channels/src/utils/plugins/plugin_setting_extraction.tsx
@@ -1,7 +1,7 @@
// 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';
+import type {BasePluginConfigurationSetting, PluginConfiguration, PluginConfigurationAction, PluginConfigurationRadioSetting, PluginConfigurationRadioSettingOption, PluginConfigurationSection} from 'types/plugins/user_settings';
export function extractPluginConfiguration(pluginConfiguration: unknown, pluginId: string) {
if (!pluginConfiguration) {
@@ -33,11 +33,17 @@ export function extractPluginConfiguration(pluginConfiguration: unknown, pluginI
return undefined;
}
+ let action;
+ if ('action' in pluginConfiguration && pluginConfiguration.action) {
+ action = extractPluginConfigurationAction(pluginConfiguration.action);
+ }
+
const result: PluginConfiguration = {
id: pluginId,
icon,
sections: [],
uiName: pluginConfiguration.uiName,
+ action,
};
for (const section of pluginConfiguration.sections) {
@@ -54,6 +60,39 @@ export function extractPluginConfiguration(pluginConfiguration: unknown, pluginI
return result;
}
+function extractPluginConfigurationAction(action: unknown): PluginConfigurationAction | undefined {
+ if (!action) {
+ return undefined;
+ }
+
+ if (typeof action !== 'object') {
+ return undefined;
+ }
+
+ if (!('title' in action) || !action.title || typeof action.title !== 'string') {
+ return undefined;
+ }
+
+ if (!('text' in action) || !action.text || typeof action.text !== 'string') {
+ return undefined;
+ }
+
+ if (!('buttonText' in action) || !action.buttonText || typeof action.buttonText !== 'string') {
+ return undefined;
+ }
+
+ if (!('onClick' in action) || !action.onClick || typeof action.onClick !== 'function') {
+ return undefined;
+ }
+
+ return {
+ title: action.title,
+ text: action.text,
+ buttonText: action.buttonText,
+ onClick: action.onClick as PluginConfigurationAction['onClick'],
+ };
+}
+
function extractPluginConfigurationSection(section: unknown) {
if (!section) {
return undefined;
@@ -84,9 +123,19 @@ function extractPluginConfigurationSection(section: unknown) {
}
}
+ let disabled;
+ if ('disabled' in section && section.disabled) {
+ if (typeof section.disabled === 'boolean') {
+ disabled = section.disabled;
+ } else {
+ return undefined;
+ }
+ }
+
const result: PluginConfigurationSection = {
settings: [],
title: section.title,
+ disabled,
onSubmit,
};