Add SectionNotice to plugin user settings (#25740)
* Add SectionNotice to plugin user settings * Make section notice more general * i18n-extract * Address feedback * Add missing import * Fix lint * Use OpenSans typography
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
0fbe3b8e9b
Коммит
721f05e01e
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className='warning-section error'>
|
||||
<i className='fa fa-exclamation-circle'/>
|
||||
<div className='warning-text'>
|
||||
<div className='warning-header'>
|
||||
<FormattedMessage
|
||||
id='sidebar_left.sidebar_channel_modal.no_common_teams_error.heading'
|
||||
defaultMessage='Unable to convert to a channel because group members are part of different teams'
|
||||
/>
|
||||
</div>
|
||||
<div className='warning-body'>
|
||||
<FormattedMessage
|
||||
id='sidebar_left.sidebar_channel_modal.no_common_teams_error.body'
|
||||
defaultMessage='Group Message cannot be converted to a channel because members are not a part of the same team. Add all members to a single team to convert this group message to a channel in that team.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SectionNotice
|
||||
title={intl.formatMessage({
|
||||
id: 'sidebar_left.sidebar_channel_modal.no_common_teams_error.heading',
|
||||
defaultMessage: 'Unable to convert to a channel because group members are part of different teams',
|
||||
})}
|
||||
text={intl.formatMessage({
|
||||
id: 'sidebar_left.sidebar_channel_modal.no_common_teams_error.body',
|
||||
defaultMessage: 'Group Message cannot be converted to a channel because members are not a part of the same team. Add all members to a single team to convert this group message to a channel in that team.',
|
||||
})}
|
||||
type={'danger'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className='warning-section'>
|
||||
<i className='fa fa-exclamation-circle'/>
|
||||
<div className='warning-text'>
|
||||
<div className='warning-header'>
|
||||
<FormattedMessage
|
||||
id='sidebar_left.sidebar_channel_modal.warning_header'
|
||||
defaultMessage='Conversation history will be visible to any channel members'
|
||||
/>
|
||||
</div>
|
||||
<div className='warning-body'>
|
||||
<FormattedMessage
|
||||
id='sidebar_left.sidebar_channel_modal.warning_body'
|
||||
defaultMessage='You are about to convert the Group Message with {memberNames} to a Channel. This cannot be undone.'
|
||||
values={{
|
||||
memberNames,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SectionNotice
|
||||
title={intl.formatMessage({
|
||||
id: 'sidebar_left.sidebar_channel_modal.warning_header',
|
||||
defaultMessage: 'Conversation history will be visible to any channel members',
|
||||
})}
|
||||
text={intl.formatMessage({
|
||||
id: 'sidebar_left.sidebar_channel_modal.warning_body',
|
||||
defaultMessage: 'You are about to convert the Group Message with {memberNames} to a Channel. This cannot be undone.',
|
||||
},
|
||||
{
|
||||
memberNames,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default WarningTextSection;
|
||||
|
||||
120
webapp/channels/src/components/section_notice.scss
Обычный файл
120
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;
|
||||
}
|
||||
70
webapp/channels/src/components/section_notice.test.tsx
Обычный файл
70
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<typeof SectionNotice>;
|
||||
|
||||
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(<SectionNotice {...props}/>);
|
||||
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(<SectionNotice {...props}/>);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
101
webapp/channels/src/components/section_notice.tsx
Обычный файл
101
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 (
|
||||
<div className={classNames('sectionNoticeContainer', type)}>
|
||||
<div className={'sectionNoticeContent'}>
|
||||
{icon && <i className={classNames('icon sectionNoticeIcon', icon, type)}/>}
|
||||
<div className='sectionNoticeBody'>
|
||||
<h4 className={classNames('sectionNoticeTitle', {welcome: type === 'welcome'})}>{title}</h4>
|
||||
<Markdown message={text}/>
|
||||
<div className='sectionNoticeActions'>
|
||||
{primaryButton &&
|
||||
<button
|
||||
onClick={primaryButton.onClick}
|
||||
className={classNames(buttonClass, 'btn-primary')}
|
||||
>
|
||||
{primaryButton.text}
|
||||
</button>
|
||||
}
|
||||
{secondaryButton &&
|
||||
<button
|
||||
onClick={secondaryButton.onClick}
|
||||
className={classNames(buttonClass, 'btn-secondary')}
|
||||
>
|
||||
{secondaryButton.text}
|
||||
</button>
|
||||
}
|
||||
{linkButton &&
|
||||
<button
|
||||
onClick={linkButton.onClick}
|
||||
className={classNames(buttonClass, 'btn-link')}
|
||||
>
|
||||
{linkButton.text}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{showDismiss &&
|
||||
<button
|
||||
className='btn btn-icon btn-sm sectionNoticeClose'
|
||||
onClick={onDismissClick}
|
||||
aria-label={intl.formatMessage({
|
||||
id: 'sectionNotice.dismiss',
|
||||
defaultMessage: 'Dismiss notice',
|
||||
})}
|
||||
>
|
||||
<i className='icon icon-close'/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionNotice;
|
||||
@@ -18,7 +18,7 @@ import UserSettingsModal from './index';
|
||||
type Props = ComponentProps<typeof UserSettingsModal>;
|
||||
|
||||
const baseProps: Props = {
|
||||
isContentProductSettings: false,
|
||||
isContentProductSettings: true,
|
||||
onExited: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -39,6 +39,40 @@ describe('do first render to avoid other testing issues', () => {
|
||||
renderWithContext(<UserSettingsModal {...baseProps}/>, 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<GlobalState> = {
|
||||
plugins: {
|
||||
userSettings: {
|
||||
plugin_a: {
|
||||
id: 'plugin_a',
|
||||
sections: [],
|
||||
uiName: uiName1,
|
||||
},
|
||||
plugin_b: {
|
||||
id: 'plugin_b',
|
||||
sections: [],
|
||||
uiName: uiName2,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<UserSettingsModal
|
||||
{...baseProps}
|
||||
isContentProductSettings={false}
|
||||
/>,
|
||||
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';
|
||||
|
||||
@@ -280,11 +280,18 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
|
||||
return (<div/>);
|
||||
}
|
||||
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<Props, State> {
|
||||
<Provider store={store}>
|
||||
<SettingsSidebar
|
||||
tabs={tabs}
|
||||
pluginTabs={Object.values(this.props.pluginSettings).map((v) => ({
|
||||
icon: v.icon ? {url: v.icon} : 'icon-power-plug-outline',
|
||||
iconTitle: v.uiName,
|
||||
name: v.id,
|
||||
uiName: v.uiName,
|
||||
}))}
|
||||
pluginTabs={pluginTabs}
|
||||
activeTab={this.state.active_tab}
|
||||
updateTab={this.updateTab}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,16 @@ exports[`plugin tab all props are properly passed to the children 1`] = `
|
||||
<SettingDesktopHeader
|
||||
text="plugin A Settings"
|
||||
/>
|
||||
<PluginAction
|
||||
action={
|
||||
Object {
|
||||
"buttonText": "buttonText",
|
||||
"onClick": [MockFunction],
|
||||
"text": "actionText",
|
||||
"title": "actionTitle",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="divider-dark first"
|
||||
/>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 = ({
|
||||
/>
|
||||
<div className='user-settings'>
|
||||
<SettingDesktopHeader text={headerText}/>
|
||||
<PluginAction action={settings.action}/>
|
||||
<div className='divider-dark first'/>
|
||||
{settings.sections.map(
|
||||
(v) =>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.pluginActionContainer {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -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<typeof PluginAction>;
|
||||
|
||||
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(<PluginAction/>);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('does show the correct information', () => {
|
||||
const props = getBaseProps();
|
||||
renderWithContext(<PluginAction {...props}/>);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ComponentProps<typeof SectionNotice>>(() => {
|
||||
return action ? {
|
||||
text: action.text,
|
||||
title: action.title,
|
||||
primaryButton: {
|
||||
onClick: action?.onClick,
|
||||
text: action?.buttonText,
|
||||
},
|
||||
} : {
|
||||
text: '',
|
||||
title: '',
|
||||
};
|
||||
}, [action]);
|
||||
|
||||
if (!action) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'pluginActionContainer'}>
|
||||
<SectionNotice {...props}/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginAction;
|
||||
@@ -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(<PluginSetting {...props}/>);
|
||||
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);
|
||||
|
||||
@@ -112,6 +112,7 @@ const PluginSetting = ({
|
||||
title={section.title}
|
||||
updateSection={updateSection}
|
||||
describe={minDescribe}
|
||||
isDisabled={section.disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Ссылка в новой задаче
Block a user