Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

28
webapp/channels/src/plugins/actions.js Обычный файл
Просмотреть файл

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionTypes} from 'utils/constants';
import {hideRHSPlugin as hideRHSPluginAction} from 'actions/views/rhs';
import {getPluggableId} from 'selectors/rhs';
export const removeWebappPlugin = (manifest) => {
return (dispatch) => {
dispatch(hideRHSPlugin(manifest.id));
dispatch({type: ActionTypes.REMOVED_WEBAPP_PLUGIN, data: manifest});
};
};
// hideRHSPlugin closes the RHS if currently showing this plugin.
const hideRHSPlugin = (manifestId) => {
return (dispatch, getState) => {
const state = getState();
const rhsPlugins = state.plugins.components.RightHandSidebarComponent || [];
const pluggableId = getPluggableId(state);
const pluginComponent = rhsPlugins.find((element) => element.id === pluggableId && element.pluginId === manifestId);
// Hide RHS if its showing this plugin
if (pluginComponent) {
dispatch(hideRHSPluginAction(pluggableId));
}
};
};

Просмотреть файл

@@ -0,0 +1,133 @@
button {
&.call-button {
display: flex;
height: 32px;
align-items: center;
justify-content: center;
padding: 10px 12px;
border-radius: 4px;
color: rgb(var(--button-bg-rgb));
fill: currentColor;
&:hover {
background: rgba(var(--button-bg-rgb), 0.08);
}
&:active,
&.active {
background: rgba(var(--button-bg-rgb), 0.16);
}
i,
span {
font-size: 14px;
line-height: 14px;
}
i::before,
span::before {
margin: 0;
}
svg {
width: 14px;
height: 14px;
}
span.call-button-label {
padding: 1px 0;
margin: 0 6px;
font-size: 12px;
font-weight: 600;
line-height: 9px;
}
&.dropdown {
color: rgba(var(--center-channel-color-rgb), 0.56);
fill: currentColor;
}
&.disabled {
color: rgba(var(--center-channel-color-rgb), 0.32);
cursor: not-allowed;
fill: currentColor;
&:hover {
background: none;
}
&:active {
background: none;
}
}
}
&.call-button-dropdown {
.Menu .MenuItem > & {
display: flex;
padding: 6px 20px 6px 16px;
margin: 0;
color: rgb(var(--button-bg-rgb));
font-size: 14px;
line-height: 20px;
svg {
width: 16px;
height: 16px;
align-self: flex-start;
margin-right: 12px;
margin-left: 4px;
fill: currentColor;
}
i {
align-self: flex-start;
margin-right: 12px;
margin-left: 4px;
color: inherit;
font-size: 16px;
&::before {
margin: 0;
}
}
.call-button-dropdown-sublabel {
margin-top: 4px;
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 12px;
line-height: 16px;
}
div {
display: flex;
flex-direction: column;
}
&.disabled {
color: rgba(var(--center-channel-color-rgb), 0.32);
fill: currentColor;
svg {
width: 16px;
height: 16px;
fill: currentColor;
}
i {
color: inherit;
font-size: 16px;
&::before {
margin: 0;
}
}
.call-button-dropdown-sublabel {
color: inherit;
font-size: 12px;
}
}
}
}
}

Просмотреть файл

@@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {CSSProperties, useState, useEffect, useRef} from 'react';
import {useIntl} from 'react-intl';
import classNames from 'classnames';
import PhoneOutlineIcon from '@mattermost/compass-icons/components/phone-outline';
import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import Menu from 'components/widgets/menu/menu';
import {Constants} from 'utils/constants';
import {Channel, ChannelMembership} from '@mattermost/types/channels';
import {PluginComponent} from 'types/store/plugins';
import './call_button.scss';
type Props = {
currentChannel: Channel;
channelMember?: ChannelMembership;
pluginCallComponents: PluginComponent[];
sidebarOpen: boolean;
}
export default function CallButton({pluginCallComponents, currentChannel, channelMember, sidebarOpen}: Props) {
const [active, setActive] = useState(false);
const [clickEnabled, setClickEnabled] = useState(true);
const prevSidebarOpen = useRef(sidebarOpen);
const {formatMessage} = useIntl();
useEffect(() => {
if (prevSidebarOpen.current && !sidebarOpen) {
setClickEnabled(false);
setTimeout(() => {
setClickEnabled(true);
}, Constants.CHANNEL_HEADER_BUTTON_DISABLE_TIMEOUT);
}
prevSidebarOpen.current = sidebarOpen;
}, [sidebarOpen]);
if (pluginCallComponents.length === 0) {
return null;
}
const style = {
container: {
marginTop: 16,
height: 32,
} as CSSProperties,
};
if (pluginCallComponents.length === 1) {
const item = pluginCallComponents[0];
const clickHandler = () => item.action?.(currentChannel, channelMember);
return (
<div
style={style.container}
className='flex-child'
onClick={clickEnabled ? clickHandler : undefined}
onTouchEnd={clickEnabled ? clickHandler : undefined}
>
{item.button}
</div>
);
}
const items = pluginCallComponents.map((item) => {
return (
<li
className='MenuItem'
key={item.id}
onClick={(e) => {
e.preventDefault();
item.action?.(currentChannel, channelMember);
}}
>
{item.dropdownButton}
</li>
);
});
return (
<div
style={style.container}
className='flex-child'
>
<MenuWrapper onToggle={(toggle: boolean) => setActive(toggle)}>
<button className={classNames('style--none call-button dropdown', {active})}>
<PhoneOutlineIcon
color='inherit'
aria-label={formatMessage({id: 'generic_icons.call', defaultMessage: 'Call icon'}).toLowerCase()}
/>
<span className='call-button-label'>{'Call'}</span>
<ChevronDownIcon
color='inherit'
aria-label={formatMessage({id: 'generic_icons.dropdown', defaultMessage: 'Dropdown icon'}).toLowerCase()}
/>
</button>
<Menu
id='callOptions'
ariaLabel={formatMessage({id: 'call_button.menuAriaLabel', defaultMessage: 'Call type selector'})}
customStyles={{
top: 'auto',
left: 'auto',
right: 0,
}}
>
{items}
</Menu>
</MenuWrapper>
</div>
);
}

Просмотреть файл

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getCurrentChannel, getMyCurrentChannelMembership} from 'mattermost-redux/selectors/entities/channels';
import {GlobalState} from 'types/store/index';
import CallButton from './call_button';
function mapStateToProps(state: GlobalState) {
return {
currentChannel: getCurrentChannel(state),
pluginCallComponents: state.plugins.components.CallButton,
channelMember: getMyCurrentChannelMembership(state),
sidebarOpen: state.views.rhs.isSidebarOpen,
};
}
export default connect(mapStateToProps)(CallButton);

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -0,0 +1,127 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Channel, ChannelMembership} from '@mattermost/types/channels';
import {Theme} from 'mattermost-redux/selectors/entities/preferences';
import ChannelHeaderPlug from 'plugins/channel_header_plug/channel_header_plug';
import {mountWithIntl} from '../../tests/helpers/intl-test-helper';
import {PluginComponent} from 'types/store/plugins';
describe('plugins/ChannelHeaderPlug', () => {
const testPlug: PluginComponent = {
id: 'someid',
pluginId: 'pluginid',
icon: <i className='fa fa-anchor'/>,
action: jest.fn,
dropdownText: 'some dropdown text',
tooltipText: 'some tooltip text',
} as PluginComponent;
test('should match snapshot with no extended component', () => {
const wrapper = mountWithIntl(
<ChannelHeaderPlug
components={[]}
channel={{} as Channel}
channelMember={{} as ChannelMembership}
theme={{} as Theme}
sidebarOpen={false}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
appBindings={[]}
appsEnabled={false}
shouldShowAppBar={false}
/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with one extended component', () => {
const wrapper = mountWithIntl(
<ChannelHeaderPlug
components={[testPlug]}
channel={{} as Channel}
channelMember={{} as ChannelMembership}
theme={{} as Theme}
sidebarOpen={false}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
appBindings={[]}
appsEnabled={false}
shouldShowAppBar={false}
/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with six extended components', () => {
const wrapper = mountWithIntl(
<ChannelHeaderPlug
components={[
testPlug,
{...testPlug, id: 'someid2'},
{...testPlug, id: 'someid3'},
{...testPlug, id: 'someid4'},
{...testPlug, id: 'someid5'},
{...testPlug, id: 'someid6'},
{...testPlug, id: 'someid7'},
{...testPlug, id: 'someid8'},
{...testPlug, id: 'someid9'},
{...testPlug, id: 'someid10'},
{...testPlug, id: 'someid11'},
{...testPlug, id: 'someid12'},
{...testPlug, id: 'someid13'},
{...testPlug, id: 'someid14'},
{...testPlug, id: 'someid15'},
]}
channel={{} as Channel}
channelMember={{} as ChannelMembership}
theme={{} as Theme}
sidebarOpen={false}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
appBindings={[]}
appsEnabled={false}
shouldShowAppBar={false}
/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot when the App Bar is visible', () => {
const wrapper = mountWithIntl(
<ChannelHeaderPlug
components={[
testPlug,
{...testPlug, id: 'someid2'},
{...testPlug, id: 'someid3'},
{...testPlug, id: 'someid4'},
]}
channel={{} as Channel}
channelMember={{} as ChannelMembership}
theme={{} as Theme}
sidebarOpen={false}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
appBindings={[]}
appsEnabled={false}
shouldShowAppBar={true}
/>,
);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -0,0 +1,361 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable react/no-multi-comp */
import React from 'react';
import {Dropdown, Tooltip} from 'react-bootstrap';
import {RootCloseWrapper} from 'react-overlays';
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl';
import {Channel, ChannelMembership} from '@mattermost/types/channels';
import {Theme} from 'mattermost-redux/selectors/entities/preferences';
import {AppBinding} from '@mattermost/types/apps';
import {AppCallResponseTypes} from 'mattermost-redux/constants/apps';
import {HandleBindingClick, OpenAppsModal, PostEphemeralCallResponseForChannel} from 'types/apps';
import HeaderIconWrapper from 'components/channel_header/components/header_icon_wrapper';
import PluginChannelHeaderIcon from 'components/widgets/icons/plugin_channel_header_icon';
import OverlayTrigger from 'components/overlay_trigger';
import {PluginComponent} from 'types/store/plugins';
import {createCallContext} from 'utils/apps';
import {Constants} from 'utils/constants';
type CustomMenuProps = {
open?: boolean;
children?: React.ReactNode;
onClose: () => void;
rootCloseEvent?: 'click' | 'mousedown';
bsRole: string;
}
class CustomMenu extends React.PureComponent<CustomMenuProps> {
handleRootClose = () => {
this.props.onClose();
}
render() {
const {
open,
rootCloseEvent,
children,
} = this.props;
return (
<RootCloseWrapper
disabled={!open}
onRootClose={this.handleRootClose}
event={rootCloseEvent}
>
<ul
role='menu'
className='dropdown-menu channel-header_plugin-dropdown'
>
{children}
</ul>
</RootCloseWrapper>
);
}
}
type CustomToggleProps = {
children?: React.ReactNode;
dropdownOpen?: boolean;
onClick?: (e: React.MouseEvent) => void;
bsRole: string;
}
class CustomToggle extends React.PureComponent<CustomToggleProps> {
handleClick = (e: React.MouseEvent) => {
if (this.props.onClick) {
this.props.onClick(e);
}
}
render() {
const {children} = this.props;
let activeClass = '';
if (this.props.dropdownOpen) {
activeClass = ' channel-header__icon--active';
}
return (
<button
id='pluginChannelHeaderButtonDropdown'
className={'channel-header__icon channel-header__icon--wide ' + activeClass}
type='button'
onClick={this.handleClick}
>
{children}
</button>
);
}
}
type ChannelHeaderPlugProps = {
intl: IntlShape;
components: PluginComponent[];
appBindings?: AppBinding[];
appsEnabled: boolean;
channel: Channel;
channelMember?: ChannelMembership;
theme: Theme;
sidebarOpen: boolean;
shouldShowAppBar: boolean;
actions: {
handleBindingClick: HandleBindingClick;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
openAppsModal: OpenAppsModal;
};
}
type ChannelHeaderPlugState = {
dropdownOpen: boolean;
}
class ChannelHeaderPlug extends React.PureComponent<ChannelHeaderPlugProps, ChannelHeaderPlugState> {
public static defaultProps: Partial<ChannelHeaderPlugProps> = {
components: [],
appBindings: [],
}
private disableButtonsClosingRHS = false;
constructor(props: ChannelHeaderPlugProps) {
super(props);
this.state = {
dropdownOpen: false,
};
}
componentDidUpdate(prevProps: ChannelHeaderPlugProps) {
if (prevProps.sidebarOpen && !this.props.sidebarOpen) {
this.disableButtonsClosingRHS = true;
setTimeout(() => {
this.disableButtonsClosingRHS = false;
}, Constants.CHANNEL_HEADER_BUTTON_DISABLE_TIMEOUT);
}
}
toggleDropdown = (dropdownOpen: boolean) => {
this.setState({dropdownOpen});
}
onClose = () => {
this.toggleDropdown(false);
}
fireAction = (action: (channel: Channel, channelMember?: ChannelMembership) => void) => {
if (this.disableButtonsClosingRHS) {
return;
}
action(this.props.channel, this.props.channelMember);
}
fireActionAndClose = (action: (channel: Channel, channelMember?: ChannelMembership) => void) => {
action(this.props.channel, this.props.channelMember);
this.onClose();
}
createComponentButton = (plug: PluginComponent) => {
return (
<HeaderIconWrapper
key={'channelHeaderButton' + plug.id}
buttonClass='channel-header__icon'
iconComponent={plug.icon!}
onClick={() => this.fireAction(plug.action!)}
buttonId={plug.id}
tooltipKey={'plugin'}
tooltipText={plug.tooltipText ? plug.tooltipText : plug.dropdownText}
pluginId={plug.pluginId}
/>
);
}
onBindingClick = async (binding: AppBinding) => {
if (this.disableButtonsClosingRHS) {
return;
}
const {channel, intl} = this.props;
const context = createCallContext(
binding.app_id,
binding.location,
this.props.channel.id,
this.props.channel.team_id,
);
const res = await this.props.actions.handleBindingClick(binding, context, intl);
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
this.props.actions.postEphemeralCallResponseForChannel(errorResponse, errorMessage, channel.id);
return;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.text) {
this.props.actions.postEphemeralCallResponseForChannel(callResp, callResp.text, channel.id);
}
break;
case AppCallResponseTypes.NAVIGATE:
break;
case AppCallResponseTypes.FORM:
if (callResp.form) {
this.props.actions.openAppsModal(callResp.form, context);
}
break;
default: {
const errorMessage = this.props.intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
this.props.actions.postEphemeralCallResponseForChannel(callResp, errorMessage, channel.id);
}
}
}
createAppBindingButton = (binding: AppBinding) => {
return (
<HeaderIconWrapper
key={`channelHeaderButton_${binding.app_id}_${binding.location}`}
buttonClass='channel-header__icon style--none'
iconComponent={(
<img
src={binding.icon}
width='24'
height='24'
/>
)}
onClick={() => this.onBindingClick(binding)}
buttonId={`${binding.app_id}_${binding.location}`}
tooltipKey={'plugin'}
tooltipText={binding.label}
/>
);
}
createDropdown = (plugs: PluginComponent[], appBindings: AppBinding[]) => {
const componentItems = plugs.filter((plug) => plug.action).map((plug) => {
return (
<li
key={'channelHeaderPlug' + plug.id}
>
<a
href='#'
className='d-flex align-items-center'
onClick={() => this.fireActionAndClose(plug.action!)}
>
<span className='d-flex align-items-center overflow--ellipsis'>{plug.icon}</span>
<span>{plug.dropdownText}</span>
</a>
</li>
);
});
let items = componentItems;
if (this.props.appsEnabled) {
items = componentItems.concat(appBindings.map((binding) => {
return (
<li
key={'channelHeaderPlug' + binding.app_id + binding.location}
>
<a
href='#'
className='d-flex align-items-center'
onClick={() => this.fireActionAndClose(() => this.onBindingClick(binding))}
>
<span className='d-flex align-items-center overflow--ellipsis icon'>{(<img src={binding.icon}/>)}</span>
<span>{binding.label}</span>
</a>
</li>
);
}));
}
return (
<div className='flex-child'>
<Dropdown
id='channelHeaderPlugDropdown'
onToggle={this.toggleDropdown}
open={this.state.dropdownOpen}
>
<CustomToggle
bsRole='toggle'
dropdownOpen={this.state.dropdownOpen}
>
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='bottom'
overlay={this.state.dropdownOpen ? <></> : (
<Tooltip id='removeIcon'>
<div aria-hidden={true}>
<FormattedMessage
id='generic_icons.plugins'
defaultMessage='Plugins'
/>
</div>
</Tooltip>
)}
>
<React.Fragment>
<PluginChannelHeaderIcon
id='pluginChannelHeaderIcon'
className='icon icon--standard icon__pluginChannelHeader'
aria-hidden='true'
/>
<span
id='pluginCount'
className='icon__text'
>
{items.length}
</span>
</React.Fragment>
</OverlayTrigger>
</CustomToggle>
<CustomMenu
bsRole='menu'
open={this.state.dropdownOpen}
onClose={this.onClose}
>
{items}
</CustomMenu>
</Dropdown>
</div>
);
}
render() {
const components = this.props.components || [];
const appBindings = this.props.appsEnabled ? this.props.appBindings || [] : [];
if (this.props.shouldShowAppBar || (components.length === 0 && appBindings.length === 0)) {
return null;
} else if ((components.length + appBindings.length) <= 15) {
let componentButtons = components.filter((plug) => plug.icon && plug.action).map(this.createComponentButton);
if (this.props.appsEnabled) {
componentButtons = componentButtons.concat(appBindings.map(this.createAppBindingButton));
}
return componentButtons;
}
return this.createDropdown(components, appBindings);
}
}
export default injectIntl(ChannelHeaderPlug);
/* eslint-enable react/no-multi-comp */

Просмотреть файл

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {appBarEnabled, appsEnabled, getChannelHeaderAppBindings} from 'mattermost-redux/selectors/entities/apps';
import {GenericAction} from 'mattermost-redux/types/actions';
import {HandleBindingClick, OpenAppsModal, PostEphemeralCallResponseForChannel} from 'types/apps';
import {handleBindingClick, openAppsModal, postEphemeralCallResponseForChannel} from 'actions/apps';
import {GlobalState} from 'types/store';
import {getChannelHeaderPluginComponents, shouldShowAppBar} from 'selectors/plugins';
import ChannelHeaderPlug from './channel_header_plug';
function mapStateToProps(state: GlobalState) {
const apps = appsEnabled(state);
return {
components: getChannelHeaderPluginComponents(state),
appBindings: getChannelHeaderAppBindings(state),
appsEnabled: apps,
appBarEnabled: appBarEnabled(state),
theme: getTheme(state),
sidebarOpen: state.views.rhs.isSidebarOpen,
shouldShowAppBar: shouldShowAppBar(state),
};
}
type Actions = {
handleBindingClick: HandleBindingClick;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
openAppsModal: OpenAppsModal;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<any>, Actions>({
handleBindingClick,
postEphemeralCallResponseForChannel,
openAppsModal,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ChannelHeaderPlug);

36
webapp/channels/src/plugins/docs.json Обычный файл
Просмотреть файл

@@ -0,0 +1,36 @@
{
"Root": {
"desc": "A component at the root of any logged-in pages. Can be used for creating modals and pop-ups that display over the whole app. Does not override any existing component.",
"props": {}
},
"PostTypePlugin": {
"desc": "The component displaying the message body of posts. For adding new components based on post type. Use in your `postTypeComponents` argument to `registerComponents` in your plugin's intialization function.",
"props": {
"post": {
"type": { "name": "object" },
"required": true,
"desc": "Post to be displayed"
},
"compactDisplay": {
"type": { "name": "bool" },
"required": false,
"defaultValue": false,
"desc": "Set to true if UI is in compact display mode"
},
"isRHS": {
"type": { "name": "bool" },
"required": true,
"defaultValue": false,
"desc": "Set to true if the post is in the right-hand sidebar (thread, search results, etc.)"
}
}
},
"ChannelHeaderButton": {
"desc": "A component on the right side of the channel header, beside buttons such as the pinned posts button.",
"props": {}
},
"MobileChannelHeaderButton": {
"desc": "Same as ChannelHeaderButton, except shown in mobile view when the screen is less than 768 pixels wide.",
"props": {}
}
}

85
webapp/channels/src/plugins/export.js Обычный файл
Просмотреть файл

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {closeRightHandSide, selectPostById} from 'actions/views/rhs';
import {getSelectedPostId, getIsRhsOpen} from 'selectors/rhs';
import BotTag from 'components/widgets/tag/bot_tag';
import messageHtmlToComponent from 'utils/message_html_to_component';
import {formatText} from 'utils/text_formatting';
import {getHistory} from 'utils/browser_history';
import {openModal} from 'actions/views/modals';
import {ModalIdentifiers} from 'utils/constants';
import {useWebSocket, useWebSocketClient, WebSocketContext} from 'utils/use_websocket';
import {imageURLForUser} from 'utils/utils';
import ChannelInviteModal from 'components/channel_invite_modal';
import ChannelMembersModal from 'components/channel_members_modal';
import PurchaseModal from 'components/purchase_modal';
import {useNotifyAdmin} from 'components/notify_admin_cta/notify_admin_cta';
import Timestamp from 'components/timestamp';
import Avatar from 'components/widgets/users/avatar';
import {openPricingModal} from '../components/global_header/right_controls/plan_upgrade_button';
import Textbox from './textbox';
// The following import has intentional side effects. Do not remove without research.
import {openInteractiveDialog} from './interactive_dialog';
// Common libraries exposed on window for plugins to use as Webpack externals.
window.React = require('react');
window.ReactDOM = require('react-dom');
window.ReactIntl = require('react-intl');
window.Redux = require('redux');
window.ReactRedux = require('react-redux');
window.ReactBootstrap = require('react-bootstrap');
window.ReactRouterDom = require('react-router-dom');
window.PropTypes = require('prop-types');
window.Luxon = require('luxon');
window.StyledComponents = require('styled-components');
// Functions exposed on window for plugins to use.
window.PostUtils = {formatText, messageHtmlToComponent};
window.openInteractiveDialog = openInteractiveDialog;
window.useNotifyAdmin = useNotifyAdmin;
window.WebappUtils = {
modals: {openModal, ModalIdentifiers},
};
Object.defineProperty(window.WebappUtils, 'browserHistory', {
get: () => getHistory(),
});
// This need to be a function because `openPricingModal`
// is initialized when `UpgradeCloudButton` is loaded.
// So if we export `openPricingModal` directly, it will be locked
// to the initial value of undefined.
window.openPricingModal = () => openPricingModal;
// Components exposed on window FOR INTERNAL PLUGIN USE ONLY. These components may have breaking changes in the future
// outside of major releases. They will be replaced by common components once that project is more mature and able to
// guarantee better compatibility.
window.Components = {
Textbox,
PurchaseModal,
Timestamp,
ChannelInviteModal,
ChannelMembersModal,
Avatar,
imageURLForUser,
BotBadge: BotTag,
};
// This is a prototype of the Product API for use by internal plugins only while we transition to the proper architecture
// for them using module federation.
window.ProductApi = {
useWebSocket,
useWebSocketClient,
WebSocketProvider: WebSocketContext,
closeRhs: closeRightHandSide,
selectRhsPost: selectPostById,
getRhsSelectedPostId: getSelectedPostId,
getIsRhsOpen,
};

243
webapp/channels/src/plugins/index.js Обычный файл
Просмотреть файл

@@ -0,0 +1,243 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import regeneratorRuntime from 'regenerator-runtime';
import {Client4} from 'mattermost-redux/client';
import {Preferences} from 'mattermost-redux/constants';
import {getConfig, isPerformanceDebuggingEnabled} from 'mattermost-redux/selectors/entities/general';
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
import store from 'stores/redux_store.jsx';
import {ActionTypes} from 'utils/constants';
import {getSiteURL} from 'utils/url';
import PluginRegistry from 'plugins/registry';
import {unregisterAllPluginWebSocketEvents, unregisterPluginReconnectHandler} from 'actions/websocket_actions.jsx';
import {unregisterPluginTranslationsSource} from 'actions/views/root';
import {unregisterAdminConsolePlugin} from 'actions/admin_actions';
import {trackPluginInitialization} from 'actions/telemetry_actions';
import {removeWebappPlugin} from './actions';
// Including the fullscreen modal css to make it available to the plugins
// (without lazy loading). This should be removed in the future whenever we
// have all plugins migrated to common components that can be reused there.
import 'components/widgets/modals/full_screen_modal.scss';
// Plugins may have been compiled with the regenerator runtime. Ensure this remains available
// as a global export even though the webapp does not depend on same.
window.regeneratorRuntime = regeneratorRuntime;
// plugins records all active web app plugins by id.
window.plugins = {};
// registerPlugin, on the global window object, should be invoked by a plugin's web app bundle as
// it is loaded.
//
// During the beta, plugins manipulated the global window.plugins data structure directly. This
// remains possible, but is officially deprecated and may be removed in a future release.
function registerPlugin(id, plugin) {
const oldPlugin = window.plugins[id];
if (oldPlugin && oldPlugin.uninitialize) {
oldPlugin.uninitialize();
}
window.plugins[id] = plugin;
}
window.registerPlugin = registerPlugin;
function arePluginsEnabled(state) {
if (getConfig(state).PluginsEnabled !== 'true') {
return false;
}
if (
isPerformanceDebuggingEnabled(state) &&
getBool(state, Preferences.CATEGORY_PERFORMANCE_DEBUGGING, Preferences.NAME_DISABLE_CLIENT_PLUGINS)
) {
return false;
}
return true;
}
// initializePlugins queries the server for all enabled plugins and loads each in turn.
export async function initializePlugins() {
if (!arePluginsEnabled(store.getState())) {
return;
}
const {data, error} = await getPlugins()(store.dispatch);
if (error) {
console.error(error); //eslint-disable-line no-console
return;
}
if (data == null || data.length === 0) {
return;
}
await Promise.all(data.map((m) => {
return loadPlugin(m).catch((loadErr) => {
console.error(loadErr.message); //eslint-disable-line no-console
});
}));
trackPluginInitialization(data);
}
// getPlugins queries the server for all enabled plugins
export function getPlugins() {
return async (dispatch) => {
let plugins;
try {
plugins = await Client4.getWebappPlugins();
} catch (error) {
return {error};
}
dispatch({type: ActionTypes.RECEIVED_WEBAPP_PLUGINS, data: plugins});
return {data: plugins};
};
}
// loadedPlugins tracks which plugins have been added as script tags to the page
const loadedPlugins = {};
// describePlugin takes a manifest and spits out a string suitable for console.log messages.
const describePlugin = (manifest) => (
'plugin ' + manifest.id + ', version ' + manifest.version
);
// loadPlugin fetches the web app bundle described by the given manifest, waits for the bundle to
// load, and then ensures the plugin has been initialized.
export function loadPlugin(manifest) {
return new Promise((resolve, reject) => {
if (!arePluginsEnabled(store.getState())) {
return;
}
// Don't load it again if previously loaded
const oldManifest = loadedPlugins[manifest.id];
if (oldManifest && oldManifest.webapp.bundle_path === manifest.webapp.bundle_path) {
resolve();
return;
}
if (oldManifest) {
// upgrading, perform cleanup
store.dispatch(removeWebappPlugin(manifest));
}
function onLoad() {
initializePlugin(manifest);
console.log('Loaded ' + describePlugin(manifest)); //eslint-disable-line no-console
resolve();
}
function onError() {
reject(new Error('Unable to load bundle for ' + describePlugin(manifest)));
}
// Backwards compatibility for old plugins
let bundlePath = manifest.webapp.bundle_path;
if (bundlePath.includes('/static/') && !bundlePath.includes('/static/plugins/')) {
bundlePath = bundlePath.replace('/static/', '/static/plugins/');
}
console.log('Loading ' + describePlugin(manifest)); //eslint-disable-line no-console
const script = document.createElement('script');
script.id = 'plugin_' + manifest.id;
script.type = 'text/javascript';
script.src = getSiteURL() + bundlePath;
script.onload = onLoad;
script.onerror = onError;
document.getElementsByTagName('head')[0].appendChild(script);
loadedPlugins[manifest.id] = manifest;
});
}
// initializePlugin creates a registry specific to the plugin and invokes any initialize function
// on the registered plugin class.
function initializePlugin(manifest) {
// Initialize the plugin
const plugin = window.plugins[manifest.id];
const registry = new PluginRegistry(manifest.id);
if (plugin && plugin.initialize) {
plugin.initialize(registry, store);
}
}
// removePlugin triggers any uninitialize callback on the registered plugin, unregisters any
// event handlers, and removes the plugin script from the DOM entirely. The plugin is responsible
// for removing any of its registered components.
export function removePlugin(manifest) {
if (!loadedPlugins[manifest.id]) {
return;
}
console.log('Removing ' + describePlugin(manifest)); //eslint-disable-line no-console
delete loadedPlugins[manifest.id];
store.dispatch(removeWebappPlugin(manifest));
const plugin = window.plugins[manifest.id];
if (plugin && plugin.uninitialize) {
plugin.uninitialize();
// Support the deprecated deinitialize callback from the plugins beta.
} else if (plugin && plugin.deinitialize) {
plugin.deinitialize();
}
unregisterAllPluginWebSocketEvents(manifest.id);
unregisterPluginReconnectHandler(manifest.id);
store.dispatch(unregisterAdminConsolePlugin(manifest.id));
unregisterPluginTranslationsSource(manifest.id);
const script = document.getElementById('plugin_' + manifest.id);
if (!script) {
return;
}
script.parentNode.removeChild(script);
console.log('Removed ' + describePlugin(manifest)); //eslint-disable-line no-console
}
// loadPluginsIfNecessary synchronizes the current state of loaded plugins with that of the server,
// loading any newly added plugins and unloading any removed ones.
export async function loadPluginsIfNecessary() {
if (!arePluginsEnabled(store.getState())) {
return;
}
const oldManifests = store.getState().plugins.plugins;
const {error} = await getPlugins()(store.dispatch);
if (error) {
console.error(error); //eslint-disable-line no-console
return;
}
const newManifests = store.getState().plugins.plugins;
// Get new plugins and update existing plugins if version changed
Object.values(newManifests).forEach((newManifest) => {
const oldManifest = oldManifests[newManifest.id];
if (!oldManifest || oldManifest.version !== newManifest.version) {
loadPlugin(newManifest).catch((loadErr) => {
console.error(loadErr.message); //eslint-disable-line no-console
});
}
});
// Remove old plugins
Object.keys(oldManifests).forEach((id) => {
if (!newManifests.hasOwnProperty(id)) {
const oldManifest = oldManifests[id];
removePlugin(oldManifest);
}
});
}

Просмотреть файл

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
IntegrationTypes,
} from 'mattermost-redux/action_types';
import {openModal} from 'actions/views/modals';
import {ModalIdentifiers} from 'utils/constants';
import InteractiveDialog from 'components/interactive_dialog';
import store from '../stores/redux_store';
export function openInteractiveDialog(dialog) {
store.dispatch({type: IntegrationTypes.RECEIVED_DIALOG, data: dialog});
store.dispatch(openModal({modalId: ModalIdentifiers.INTERACTIVE_DIALOG, dialogType: InteractiveDialog}));
}
// This code is problematic for a couple of different reasons:
// * it monitors the store to modify the store: this is perhaps better handled by a saga
// * it makes importing this file impure by triggering a side-effect which may not be obvious
// * it's not really located in the "right place": dialogs are applicable to non-plugins too
// * it's nigh impossible to test as written
//
// It's worth fixing all of this, but I think this requires some refactoring.
let previousTriggerId = '';
store.subscribe(() => {
const state = store.getState();
const currentTriggerId = state.entities.integrations.dialogTriggerId;
if (currentTriggerId === previousTriggerId) {
return;
}
previousTriggerId = currentTriggerId;
const dialog = state.entities.integrations.dialog || {};
if (dialog.trigger_id !== currentTriggerId) {
return;
}
store.dispatch(openModal({modalId: ModalIdentifiers.INTERACTIVE_DIALOG, dialogType: InteractiveDialog}));
});

Просмотреть файл

@@ -0,0 +1,987 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`plugins/MobileChannelHeaderPlug should match snapshot with no binding, in dropdown 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={Array []}
appsEnabled={true}
channel={Object {}}
channelMember={Object {}}
components={Array []}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
isDropdown={true}
theme={Object {}}
/>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with no bindings 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={Array []}
appsEnabled={true}
channel={Object {}}
channelMember={Object {}}
components={Array []}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
isDropdown={false}
theme={Object {}}
/>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with no extended component 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={Array []}
appsEnabled={false}
channel={Object {}}
channelMember={Object {}}
components={Array []}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
isDropdown={false}
theme={Object {}}
/>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with no extended component, in dropdown 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={Array []}
appsEnabled={false}
channel={Object {}}
channelMember={Object {}}
components={Array []}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
isDropdown={true}
theme={Object {}}
/>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with one binding 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={
Array [
Object {
"app_id": "appid",
"form": Object {
"submit": Object {
"path": "/call/path",
},
},
"hint": "Hint",
"icon": "http://test.com/icon.png",
"label": "Label",
"location": "test",
},
]
}
appsEnabled={true}
channel={Object {}}
channelMember={Object {}}
components={Array []}
intl={
Object {
"formatMessage": [Function],
}
}
isDropdown={false}
theme={Object {}}
>
<li
className="flex-parent--center"
>
<button
className="navbar-toggle navbar-right__icon"
id="appid_test"
onClick={[Function]}
>
<span
className="icon navbar-plugin-button"
>
<img
height="16"
src="http://test.com/icon.png"
width="16"
/>
</span>
</button>
</li>
</MobileChannelHeaderPlug>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with one binding, in dropdown 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={
Array [
Object {
"app_id": "appid",
"form": Object {
"submit": Object {
"path": "/call/path",
},
},
"hint": "Hint",
"icon": "http://test.com/icon.png",
"label": "Label",
"location": "test",
},
]
}
appsEnabled={true}
channel={Object {}}
channelMember={Object {}}
components={Array []}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
isDropdown={true}
theme={Object {}}
>
<li
className="MenuItem"
key="mobileChannelHeaderItemappidtest"
role="presentation"
>
<a
href="#"
onClick={[Function]}
role="menuitem"
>
Label
</a>
</li>
</MobileChannelHeaderPlug>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with one extended component 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={Array []}
appsEnabled={false}
channel={Object {}}
channelMember={Object {}}
components={
Array [
Object {
"action": [MockFunction],
"dropdownText": "some dropdown text",
"icon": <i
className="fa fa-anchor"
/>,
"id": "someid",
"pluginId": "pluginid",
},
]
}
intl={
Object {
"formatMessage": [Function],
}
}
isDropdown={false}
theme={Object {}}
>
<li
className="flex-parent--center"
>
<button
className="navbar-toggle navbar-right__icon"
onClick={[Function]}
>
<span
className="icon navbar-plugin-button"
>
<i
className="fa fa-anchor"
/>
</span>
</button>
</li>
</MobileChannelHeaderPlug>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with one extended component and one binding, in dropdown 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={
Array [
Object {
"app_id": "appid",
"form": Object {
"submit": Object {
"path": "/call/path",
},
},
"hint": "Hint",
"icon": "http://test.com/icon.png",
"label": "Label",
"location": "test",
},
]
}
appsEnabled={true}
channel={Object {}}
channelMember={Object {}}
components={
Array [
Object {
"action": [MockFunction],
"dropdownText": "some dropdown text",
"icon": <i
className="fa fa-anchor"
/>,
"id": "someid",
"pluginId": "pluginid",
},
]
}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
isDropdown={true}
theme={Object {}}
>
<li
className="MenuItem"
key="mobileChannelHeaderItemsomeid"
role="presentation"
>
<a
href="#"
onClick={[Function]}
role="menuitem"
>
some dropdown text
</a>
</li>
<li
className="MenuItem"
key="mobileChannelHeaderItemappidtest"
role="presentation"
>
<a
href="#"
onClick={[Function]}
role="menuitem"
>
Label
</a>
</li>
</MobileChannelHeaderPlug>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with one extended component, in dropdown 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={Array []}
appsEnabled={false}
channel={Object {}}
channelMember={Object {}}
components={
Array [
Object {
"action": [MockFunction],
"dropdownText": "some dropdown text",
"icon": <i
className="fa fa-anchor"
/>,
"id": "someid",
"pluginId": "pluginid",
},
]
}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
isDropdown={true}
theme={Object {}}
>
<li
className="MenuItem"
key="mobileChannelHeaderItemsomeid"
role="presentation"
>
<a
href="#"
onClick={[Function]}
role="menuitem"
>
some dropdown text
</a>
</li>
</MobileChannelHeaderPlug>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with one extended components and one binding 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={
Array [
Object {
"app_id": "appid",
"form": Object {
"submit": Object {
"path": "/call/path",
},
},
"hint": "Hint",
"icon": "http://test.com/icon.png",
"label": "Label",
"location": "test",
},
]
}
appsEnabled={true}
channel={Object {}}
channelMember={Object {}}
components={
Array [
Object {
"action": [MockFunction],
"dropdownText": "some dropdown text",
"icon": <i
className="fa fa-anchor"
/>,
"id": "someid",
"pluginId": "pluginid",
},
]
}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
isDropdown={false}
theme={Object {}}
/>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with two bindings 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={
Array [
Object {
"app_id": "appid",
"form": Object {
"submit": Object {
"path": "/call/path",
},
},
"hint": "Hint",
"icon": "http://test.com/icon.png",
"label": "Label",
"location": "test",
},
Object {
"app_id": "app2",
"form": Object {
"submit": Object {
"path": "/call/path",
},
},
"hint": "Hint",
"icon": "http://test.com/icon.png",
"label": "Label",
"location": "test",
},
]
}
appsEnabled={false}
channel={Object {}}
channelMember={Object {}}
components={Array []}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
isDropdown={false}
theme={Object {}}
/>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with two bindings, in dropdown 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={
Array [
Object {
"app_id": "appid",
"form": Object {
"submit": Object {
"path": "/call/path",
},
},
"hint": "Hint",
"icon": "http://test.com/icon.png",
"label": "Label",
"location": "test",
},
Object {
"app_id": "app2",
"form": Object {
"submit": Object {
"path": "/call/path",
},
},
"hint": "Hint",
"icon": "http://test.com/icon.png",
"label": "Label",
"location": "test",
},
]
}
appsEnabled={true}
channel={Object {}}
channelMember={Object {}}
components={Array []}
intl={
Object {
"formatMessage": [Function],
}
}
isDropdown={true}
theme={Object {}}
>
<li
className="MenuItem"
key="mobileChannelHeaderItemappidtest"
role="presentation"
>
<a
href="#"
onClick={[Function]}
role="menuitem"
>
Label
</a>
</li>
<li
className="MenuItem"
key="mobileChannelHeaderItemapp2test"
role="presentation"
>
<a
href="#"
onClick={[Function]}
role="menuitem"
>
Label
</a>
</li>
</MobileChannelHeaderPlug>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with two extended components 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={Array []}
appsEnabled={false}
channel={Object {}}
channelMember={Object {}}
components={
Array [
Object {
"action": [MockFunction],
"dropdownText": "some dropdown text",
"icon": <i
className="fa fa-anchor"
/>,
"id": "someid",
"pluginId": "pluginid",
},
Object {
"action": [MockFunction],
"dropdownText": "some dropdown text",
"icon": <i
className="fa fa-anchor"
/>,
"id": "someid2",
"pluginId": "pluginid",
},
]
}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
isDropdown={false}
theme={Object {}}
/>
`;
exports[`plugins/MobileChannelHeaderPlug should match snapshot with two extended components, in dropdown 1`] = `
<MobileChannelHeaderPlug
actions={
Object {
"handleBindingClick": [MockFunction],
"openAppsModal": [MockFunction],
"postEphemeralCallResponseForChannel": [MockFunction],
}
}
appBindings={Array []}
appsEnabled={false}
channel={Object {}}
channelMember={Object {}}
components={
Array [
Object {
"action": [MockFunction],
"dropdownText": "some dropdown text",
"icon": <i
className="fa fa-anchor"
/>,
"id": "someid",
"pluginId": "pluginid",
},
Object {
"action": [MockFunction],
"dropdownText": "some dropdown text",
"icon": <i
className="fa fa-anchor"
/>,
"id": "someid2",
"pluginId": "pluginid",
},
]
}
intl={
Object {
"formatMessage": [Function],
}
}
isDropdown={true}
theme={Object {}}
>
<li
className="MenuItem"
key="mobileChannelHeaderItemsomeid"
role="presentation"
>
<a
href="#"
onClick={[Function]}
role="menuitem"
>
some dropdown text
</a>
</li>
<li
className="MenuItem"
key="mobileChannelHeaderItemsomeid2"
role="presentation"
>
<a
href="#"
onClick={[Function]}
role="menuitem"
>
some dropdown text
</a>
</li>
</MobileChannelHeaderPlug>
`;

Просмотреть файл

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getMyCurrentChannelMembership} from 'mattermost-redux/selectors/entities/channels';
import {appsEnabled, makeAppBindingsSelector} from 'mattermost-redux/selectors/entities/apps';
import {AppBindingLocations} from 'mattermost-redux/constants/apps';
import {GlobalState} from 'types/store';
import {GenericAction} from 'mattermost-redux/types/actions';
import {handleBindingClick, openAppsModal, postEphemeralCallResponseForChannel} from 'actions/apps';
import {HandleBindingClick, OpenAppsModal, PostEphemeralCallResponseForChannel} from 'types/apps';
import MobileChannelHeaderPlug from './mobile_channel_header_plug';
const getChannelHeaderBindings = makeAppBindingsSelector(AppBindingLocations.CHANNEL_HEADER_ICON);
function mapStateToProps(state: GlobalState) {
const apps = appsEnabled(state);
return {
appBindings: getChannelHeaderBindings(state),
appsEnabled: apps,
channelMember: getMyCurrentChannelMembership(state),
components: state.plugins.components.MobileChannelHeaderButton,
theme: getTheme(state),
};
}
type Actions = {
handleBindingClick: HandleBindingClick;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
openAppsModal: OpenAppsModal;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<any>, Actions>({
handleBindingClick,
postEphemeralCallResponseForChannel,
openAppsModal,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(MobileChannelHeaderPlug);

Просмотреть файл

@@ -0,0 +1,472 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {mount} from 'enzyme';
import MobileChannelHeaderPlug, {RawMobileChannelHeaderPlug} from 'plugins/mobile_channel_header_plug/mobile_channel_header_plug';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import {createCallContext} from 'utils/apps';
import {AppCallResponseTypes} from 'mattermost-redux/constants/apps';
import {Channel, ChannelMembership} from '@mattermost/types/channels';
import {Theme} from 'mattermost-redux/selectors/entities/preferences';
describe('plugins/MobileChannelHeaderPlug', () => {
const testPlug = {
id: 'someid',
pluginId: 'pluginid',
icon: <i className='fa fa-anchor'/>,
action: jest.fn(),
dropdownText: 'some dropdown text',
};
const testBinding = {
app_id: 'appid',
location: 'test',
icon: 'http://test.com/icon.png',
label: 'Label',
hint: 'Hint',
form: {
submit: {
path: '/call/path',
},
},
};
const testChannel = {} as Channel;
const testChannelMember = {} as ChannelMembership;
const testTheme = {} as Theme;
const intl = {
formatMessage: (message: {id: string; defaultMessage: string}) => {
return message.defaultMessage;
},
} as any;
test('should match snapshot with no extended component', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with one extended component', () => {
const wrapper = mount<RawMobileChannelHeaderPlug>(
<RawMobileChannelHeaderPlug
components={[testPlug]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
intl={intl}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a single list item containing a button
expect(wrapper.find('li')).toHaveLength(1);
expect(wrapper.find('button')).toHaveLength(1);
wrapper.instance().fireAction = jest.fn();
wrapper.find('button').first().simulate('click');
expect(wrapper.instance().fireAction).toHaveBeenCalledTimes(1);
expect(wrapper.instance().fireAction).toBeCalledWith(testPlug);
});
test('should match snapshot with two extended components', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[testPlug, {...testPlug, id: 'someid2'}]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with no bindings', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={true}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with one binding', () => {
const wrapper = mount<RawMobileChannelHeaderPlug>(
<RawMobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={true}
appBindings={[testBinding]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
intl={intl}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a single list item containing a button
expect(wrapper.find('li')).toHaveLength(1);
expect(wrapper.find('button')).toHaveLength(1);
wrapper.instance().fireAppAction = jest.fn();
wrapper.find('button').first().simulate('click');
expect(wrapper.instance().fireAppAction).toHaveBeenCalledTimes(1);
expect(wrapper.instance().fireAppAction).toBeCalledWith(testBinding);
});
test('should match snapshot with two bindings', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={false}
appBindings={[testBinding, {...testBinding, app_id: 'app2'}]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with one extended components and one binding', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[testPlug]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={true}
appBindings={[testBinding]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with no extended component, in dropdown', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with one extended component, in dropdown', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[testPlug]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a single list item containing an anchor
expect(wrapper.find('li')).toHaveLength(1);
expect(wrapper.find('a')).toHaveLength(1);
});
test('should match snapshot with two extended components, in dropdown', () => {
const wrapper = mount<RawMobileChannelHeaderPlug>(
<RawMobileChannelHeaderPlug
components={[testPlug, {...testPlug, id: 'someid2'}]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
intl={intl}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a two list items containing anchors
expect(wrapper.find('li')).toHaveLength(2);
expect(wrapper.find('a')).toHaveLength(2);
const instance = wrapper.instance();
instance.fireAction = jest.fn();
wrapper.find('a').first().simulate('click');
expect(instance.fireAction).toHaveBeenCalledTimes(1);
expect(instance.fireAction).toBeCalledWith(testPlug);
});
test('should match snapshot with no binding, in dropdown', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={true}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with one binding, in dropdown', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={true}
appBindings={[testBinding]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a single list item containing an anchor
expect(wrapper.find('li')).toHaveLength(1);
expect(wrapper.find('a')).toHaveLength(1);
});
test('should match snapshot with two bindings, in dropdown', () => {
const wrapper = mount<RawMobileChannelHeaderPlug>(
<RawMobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={true}
appBindings={[testBinding, {...testBinding, app_id: 'app2'}]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
intl={intl}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a two list items containing anchors
expect(wrapper.find('li')).toHaveLength(2);
expect(wrapper.find('a')).toHaveLength(2);
const instance = wrapper.instance();
instance.fireAppAction = jest.fn();
wrapper.find('a').first().simulate('click');
expect(instance.fireAppAction).toHaveBeenCalledTimes(1);
expect(instance.fireAppAction).toBeCalledWith(testBinding);
});
test('should match snapshot with one extended component and one binding, in dropdown', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[testPlug]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={true}
appBindings={[testBinding]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a two list items containing anchors
expect(wrapper.find('li')).toHaveLength(2);
expect(wrapper.find('a')).toHaveLength(2);
});
test('should call plugin.action on fireAction', () => {
const channel = {id: 'channel_id'} as Channel;
const channelMember = {} as ChannelMembership;
const newTestPlug = {
id: 'someid',
pluginId: 'pluginid',
icon: <i className='fa fa-anchor'/>,
action: jest.fn(),
dropdownText: 'some dropdown text',
};
const wrapper = mount<RawMobileChannelHeaderPlug>(
<RawMobileChannelHeaderPlug
components={[newTestPlug]}
channel={channel}
channelMember={channelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
intl={intl}
/>,
);
wrapper.instance().fireAction(newTestPlug);
expect(newTestPlug.action).toHaveBeenCalledTimes(1);
expect(newTestPlug.action).toBeCalledWith(channel, channelMember);
});
test('should call handleBindingClick on fireAppAction', () => {
const channel = {id: 'channel_id'} as Channel;
const channelMember = {} as ChannelMembership;
const handleBindingClick = jest.fn().mockResolvedValue({data: {type: AppCallResponseTypes.OK}});
const wrapper = mount<RawMobileChannelHeaderPlug>(
<RawMobileChannelHeaderPlug
components={[]}
channel={channel}
channelMember={channelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={true}
appBindings={[testBinding]}
actions={{
handleBindingClick,
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
intl={intl}
/>,
);
const context = createCallContext(
testBinding.app_id,
testBinding.location,
channel.id,
channel.team_id,
);
wrapper.instance().fireAppAction(testBinding);
expect(handleBindingClick).toHaveBeenCalledTimes(1);
expect(handleBindingClick).toBeCalledWith(testBinding, context, expect.anything());
});
});

Просмотреть файл

@@ -0,0 +1,207 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {injectIntl, IntlShape} from 'react-intl';
import {AppCallResponseTypes} from 'mattermost-redux/constants/apps';
import {AppBinding} from '@mattermost/types/apps';
import {Channel, ChannelMembership} from '@mattermost/types/channels';
import {Theme} from 'mattermost-redux/selectors/entities/preferences';
import {PluginComponent} from 'types/store/plugins';
import {createCallContext} from 'utils/apps';
import {HandleBindingClick, OpenAppsModal, PostEphemeralCallResponseForChannel} from 'types/apps';
type Props = {
/*
* Components or actions to add as channel header buttons
*/
components?: PluginComponent[];
/*
* Set to true if the plug is in the dropdown
*/
isDropdown: boolean;
channel: Channel;
channelMember?: ChannelMembership;
/*
* Logged in user's theme
*/
theme: Theme;
appBindings: AppBinding[];
appsEnabled: boolean;
intl: IntlShape;
actions: {
handleBindingClick: HandleBindingClick;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
openAppsModal: OpenAppsModal;
};
}
class MobileChannelHeaderPlug extends React.PureComponent<Props> {
createAppButton = (binding: AppBinding) => {
const onClick = () => this.fireAppAction(binding);
if (this.props.isDropdown) {
return (
<li
key={'mobileChannelHeaderItem' + binding.app_id + binding.location}
role='presentation'
className='MenuItem'
>
<a
role='menuitem'
href='#'
onClick={onClick}
>
{binding.label}
</a>
</li>
);
}
return (
<li className='flex-parent--center'>
<button
id={`${binding.app_id}_${binding.location}`}
className='navbar-toggle navbar-right__icon'
onClick={onClick}
>
<span className='icon navbar-plugin-button'>
<img
src={binding.icon}
width='16'
height='16'
/>
</span>
</button>
</li>
);
}
createButton = (plug: PluginComponent) => {
const onClick = () => this.fireAction(plug);
if (this.props.isDropdown) {
return (
<li
key={'mobileChannelHeaderItem' + plug.id}
role='presentation'
className='MenuItem'
>
<a
role='menuitem'
href='#'
onClick={onClick}
>
{plug.dropdownText}
</a>
</li>
);
}
return (
<li className='flex-parent--center'>
<button
className='navbar-toggle navbar-right__icon'
onClick={onClick}
>
<span className='icon navbar-plugin-button'>
{plug.icon}
</span>
</button>
</li>
);
}
createList(plugs: PluginComponent[]) {
return plugs.map(this.createButton);
}
createAppList(bindings: AppBinding[]) {
return bindings.map(this.createAppButton);
}
fireAction(plug: PluginComponent) {
return plug.action?.(this.props.channel, this.props.channelMember);
}
fireAppAction = async (binding: AppBinding) => {
const {channel, intl} = this.props;
const context = createCallContext(
binding.app_id,
binding.location,
channel.id,
channel.team_id,
);
const res = await this.props.actions.handleBindingClick(binding, context, intl);
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
this.props.actions.postEphemeralCallResponseForChannel(errorResponse, errorMessage, channel.id);
return;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.text) {
this.props.actions.postEphemeralCallResponseForChannel(callResp, callResp.text, channel.id);
}
break;
case AppCallResponseTypes.NAVIGATE:
break;
case AppCallResponseTypes.FORM:
if (callResp.form) {
this.props.actions.openAppsModal(callResp.form, context);
}
break;
default: {
const errorMessage = this.props.intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
this.props.actions.postEphemeralCallResponseForChannel(callResp, errorMessage, channel.id);
}
}
}
render() {
const components = this.props.components || [];
const bindings = this.props.appBindings || [];
if (components.length === 0 && bindings.length === 0) {
return null;
} else if (components.length === 1 && bindings.length === 0) {
return this.createButton(components[0]);
} else if (components.length === 0 && bindings.length === 1) {
return this.createAppButton(bindings[0]);
}
if (!this.props.isDropdown) {
return null;
}
const plugItems = this.createList(components);
const appItems = this.createAppList(bindings);
return (<>
{plugItems}
{appItems}
</>);
}
}
// Exported for tests
export {MobileChannelHeaderPlug as RawMobileChannelHeaderPlug};
export default injectIntl(MobileChannelHeaderPlug);

Просмотреть файл

@@ -0,0 +1,239 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`plugins/Pluggable should match snapshot with extended component 1`] = `
<Pluggable
components={
Object {
"PopoverSection1": Array [
Object {
"component": [Function],
},
],
}
}
pluggableName="PopoverSection1"
theme={Object {}}
>
<PluggableErrorBoundary
key="PopoverSection1undefined"
>
<ProfilePopoverPlugin
theme={Object {}}
webSocketClient={
WebSocketClient {
"closeCallback": null,
"closeListeners": Set {},
"conn": null,
"connectFailCount": 0,
"connectionId": "",
"connectionUrl": null,
"errorCallback": null,
"errorListeners": Set {},
"eventCallback": null,
"firstConnectCallback": null,
"firstConnectListeners": Set {},
"messageListeners": Set {},
"missedEventCallback": null,
"missedMessageListeners": Set {},
"reconnectCallback": null,
"reconnectListeners": Set {},
"responseCallbacks": Object {},
"responseSequence": 1,
"serverSequence": 0,
}
}
>
<span
id="pluginId"
>
ProfilePopoverPlugin
</span>
</ProfilePopoverPlugin>
</PluggableErrorBoundary>
</Pluggable>
`;
exports[`plugins/Pluggable should match snapshot with extended component with pluggableName 1`] = `
<Pluggable
components={
Object {
"PopoverSection1": Array [
Object {
"component": [Function],
},
],
}
}
pluggableName="PopoverSection1"
theme={Object {}}
>
<PluggableErrorBoundary
key="PopoverSection1undefined"
>
<ProfilePopoverPlugin
theme={Object {}}
webSocketClient={
WebSocketClient {
"closeCallback": null,
"closeListeners": Set {},
"conn": null,
"connectFailCount": 0,
"connectionId": "",
"connectionUrl": null,
"errorCallback": null,
"errorListeners": Set {},
"eventCallback": null,
"firstConnectCallback": null,
"firstConnectListeners": Set {},
"messageListeners": Set {},
"missedEventCallback": null,
"missedMessageListeners": Set {},
"reconnectCallback": null,
"reconnectListeners": Set {},
"responseCallbacks": Object {},
"responseSequence": 1,
"serverSequence": 0,
}
}
>
<span
id="pluginId"
>
ProfilePopoverPlugin
</span>
</ProfilePopoverPlugin>
</PluggableErrorBoundary>
</Pluggable>
`;
exports[`plugins/Pluggable should match snapshot with no extended component 1`] = `
<Pluggable
components={Object {}}
pluggableName=""
theme={Object {}}
/>
`;
exports[`plugins/Pluggable should match snapshot with non-null pluggableId 1`] = `
<Pluggable
components={
Object {
"PopoverSection1": Array [
Object {
"component": [Function],
},
],
}
}
pluggableId="pluggableId"
pluggableName="PopoverSection1"
/>
`;
exports[`plugins/Pluggable should match snapshot with null pluggableId 1`] = `
<Pluggable
components={
Object {
"PopoverSection1": Array [
Object {
"component": [Function],
},
],
}
}
pluggableName="PopoverSection1"
theme={Object {}}
>
<PluggableErrorBoundary
key="PopoverSection1undefined"
>
<ProfilePopoverPlugin
theme={Object {}}
webSocketClient={
WebSocketClient {
"closeCallback": null,
"closeListeners": Set {},
"conn": null,
"connectFailCount": 0,
"connectionId": "",
"connectionUrl": null,
"errorCallback": null,
"errorListeners": Set {},
"eventCallback": null,
"firstConnectCallback": null,
"firstConnectListeners": Set {},
"messageListeners": Set {},
"missedEventCallback": null,
"missedMessageListeners": Set {},
"reconnectCallback": null,
"reconnectListeners": Set {},
"responseCallbacks": Object {},
"responseSequence": 1,
"serverSequence": 0,
}
}
>
<span
id="pluginId"
>
ProfilePopoverPlugin
</span>
</ProfilePopoverPlugin>
</PluggableErrorBoundary>
</Pluggable>
`;
exports[`plugins/Pluggable should match snapshot with valid pluggableId 1`] = `
<Pluggable
components={
Object {
"PopoverSection1": Array [
Object {
"component": [Function],
"id": "pluggableId",
},
],
}
}
pluggableId="pluggableId"
pluggableName="PopoverSection1"
theme={Object {}}
>
<PluggableErrorBoundary
key="PopoverSection1pluggableId"
>
<ProfilePopoverPlugin
theme={Object {}}
webSocketClient={
WebSocketClient {
"closeCallback": null,
"closeListeners": Set {},
"conn": null,
"connectFailCount": 0,
"connectionId": "",
"connectionUrl": null,
"errorCallback": null,
"errorListeners": Set {},
"eventCallback": null,
"firstConnectCallback": null,
"firstConnectListeners": Set {},
"messageListeners": Set {},
"missedEventCallback": null,
"missedMessageListeners": Set {},
"reconnectCallback": null,
"reconnectListeners": Set {},
"responseCallbacks": Object {},
"responseSequence": 1,
"serverSequence": 0,
}
}
>
<span
id="pluginId"
>
ProfilePopoverPlugin
</span>
</ProfilePopoverPlugin>
</PluggableErrorBoundary>
</Pluggable>
`;

Просмотреть файл

@@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import styled from 'styled-components';
type Props = {
children: React.ReactNode;
pluginId?: string;
}
type State = {
hasError: boolean;
}
const WrapperDiv = styled.div`
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
#root > & {
// prevent root layout error; fit into announcement area
grid-area: announcement;
display: flex;
word-break: normal;
flex-direction: row;
gap: 10px;
height: 40px;
}
`;
export default class PluggableErrorBoundary extends React.PureComponent<Props, State> {
state = {
hasError: false,
};
static getDerivedStateFromError() {
return {
hasError: true,
};
}
clearErrorState = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.setState({hasError: false});
}
render() {
if (this.state.hasError) {
return (
<WrapperDiv>
<FormattedMessage
id='pluggable.errorOccurred'
defaultMessage='An error occurred in the {pluginId} plugin.'
values={{
pluginId: this.props.pluginId,
}}
/>
<br/>
<a
href='#'
onClick={this.clearErrorState}
>
<FormattedMessage
id='pluggable.errorRefresh'
defaultMessage='Refresh?'
values={{
pluginId: this.props.pluginId,
}}
/>
</a>
</WrapperDiv>
);
}
return this.props.children;
}
}

Просмотреть файл

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {GlobalState} from 'types/store';
import Pluggable from './pluggable';
function mapStateToProps(state: GlobalState) {
return {
components: state.plugins.components,
theme: getTheme(state),
};
}
export default connect(mapStateToProps)(Pluggable);

Просмотреть файл

@@ -0,0 +1,124 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import Pluggable from './pluggable';
class ProfilePopoverPlugin extends React.PureComponent {
render() {
return <span id='pluginId'>{'ProfilePopoverPlugin'}</span>;
}
}
jest.mock('actions/views/profile_popover');
describe('plugins/Pluggable', () => {
const baseProps = {
pluggableName: '',
components: {},
theme: {},
};
test('should match snapshot with no extended component', () => {
const wrapper = mountWithIntl(
<Pluggable
{...baseProps}
/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with extended component', () => {
const wrapper = mountWithIntl(
<Pluggable
{...baseProps}
pluggableName='PopoverSection1'
components={{PopoverSection1: [{component: ProfilePopoverPlugin}]}}
/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('#pluginId').text()).toBe('ProfilePopoverPlugin');
expect(wrapper.find(ProfilePopoverPlugin).exists()).toBe(true);
});
test('should match snapshot with extended component with pluggableName', () => {
const wrapper = mountWithIntl(
<Pluggable
{...baseProps}
pluggableName='PopoverSection1'
components={{PopoverSection1: [{component: ProfilePopoverPlugin}]}}
/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('#pluginId').text()).toBe('ProfilePopoverPlugin');
expect(wrapper.find(ProfilePopoverPlugin).exists()).toBe(true);
});
test('should return null if neither pluggableName nor children is is defined in props', () => {
const wrapper = mountWithIntl(
<Pluggable
{...baseProps}
components={{PopoverSection1: [{component: ProfilePopoverPlugin}]}}
/>,
);
expect(wrapper.find(ProfilePopoverPlugin).exists()).toBe(false);
});
test('should return null if with pluggableName but no children', () => {
const wrapper = mountWithIntl(
<Pluggable
{...baseProps}
pluggableName='PopoverSection1'
/>,
);
expect(wrapper.children().length).toBe(0);
});
test('should match snapshot with non-null pluggableId', () => {
const wrapper = mountWithIntl(
<Pluggable
pluggableName='PopoverSection1'
pluggableId={'pluggableId'}
components={{PopoverSection1: [{component: ProfilePopoverPlugin}]}}
/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find(ProfilePopoverPlugin).exists()).toBe(false);
});
test('should match snapshot with null pluggableId', () => {
const wrapper = mountWithIntl(
<Pluggable
{...baseProps}
pluggableName='PopoverSection1'
components={{PopoverSection1: [{component: ProfilePopoverPlugin}]}}
/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find(ProfilePopoverPlugin).exists()).toBe(true);
});
test('should match snapshot with valid pluggableId', () => {
const wrapper = mountWithIntl(
<Pluggable
{...baseProps}
pluggableName='PopoverSection1'
pluggableId={'pluggableId'}
components={{PopoverSection1: [{id: 'pluggableId', component: ProfilePopoverPlugin}]}}
/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find(ProfilePopoverPlugin).exists()).toBe(true);
});
});

Просмотреть файл

@@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {WebSocketClient} from '@mattermost/client';
import {Theme} from 'mattermost-redux/selectors/entities/preferences';
import {ProductComponent} from 'types/store/plugins';
import {GlobalState} from 'types/store';
import webSocketClient from 'client/web_websocket_client';
import PluggableErrorBoundary from './error_boundary';
type Props = {
/*
* Override the component to be plugged
*/
pluggableName: string;
/*
* Components for overriding provided by plugins
*/
components: GlobalState['plugins']['components'];
/*
* Logged in user's theme
*/
theme: Theme;
/*
* Id of the specific component to be plugged.
*/
pluggableId?: string;
/*
* Name of the sub component to use. Defaults to 'component' if unspecified.
*
* Only supported when pluggableName is "Product".
*/
subComponentName?: 'mainComponent' | 'headerCentreComponent' | 'headerRightComponent';
/*
* Accept any other prop to pass onto the plugin component
*/
[name: string]: any;
}
type BaseChildProps = {
theme: Theme;
webSocketClient?: WebSocketClient;
}
export default function Pluggable(props: Props): JSX.Element | null {
const {
components,
pluggableId,
pluggableName,
subComponentName = '',
theme,
...otherProps
} = props;
if (!pluggableName || !Object.hasOwnProperty.call(components, pluggableName)) {
return null;
}
let pluginComponents = components[pluggableName]!;
if (pluggableId) {
pluginComponents = pluginComponents.filter(
(element) => element.id === pluggableId);
}
// Override the default component with any registered plugin's component
// Select a specific component by pluginId if available
let content;
if (pluggableName === 'Product') {
content = (pluginComponents as ProductComponent[]).map((pc) => {
if (!subComponentName || !pc[subComponentName]) {
return null;
}
const Component = pc[subComponentName]! as React.ComponentType<BaseChildProps>;
return (
<PluggableErrorBoundary
key={pluggableName + pc.id}
pluginId={pc.pluginId}
>
<Component
{...otherProps}
theme={theme}
/>
</PluggableErrorBoundary>
);
});
} else {
content = pluginComponents.map((p) => {
if (!p.component) {
return null;
}
const Component = p.component as React.ComponentType<BaseChildProps>;
return (
<PluggableErrorBoundary
key={pluggableName + p.id}
pluginId={p.pluginId}
>
<Component
{...otherProps}
theme={theme}
webSocketClient={webSocketClient}
/>
</PluggableErrorBoundary>
);
});
}
return (
<React.Fragment>
{content}
</React.Fragment>
);
}

Просмотреть файл

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from 'mattermost-redux/client';
import testConfigureStore from 'tests/test_store';
import {initializeProducts} from './products';
(window as any).REMOTE_CONTAINERS = {};
describe('initializeProducts', () => {
test('should set Client4 to use the correct Boards URL for product mode', async () => {
const store = testConfigureStore({
entities: {
general: {
config: {
FeatureFlagBoardsProduct: 'true',
},
},
},
});
await store.dispatch(initializeProducts());
expect(Client4.getBoardsRoute().startsWith('/plugins/boards')).toBe(true);
});
test('should set Client4 to use the correct Boards URL for plugin mode', async () => {
const store = testConfigureStore({
entities: {
general: {
config: {
FeatureFlagBoardsProduct: 'false',
},
},
},
});
await store.dispatch(initializeProducts());
expect(Client4.getBoardsRoute().startsWith('/plugins/focalboard')).toBe(true);
});
});

126
webapp/channels/src/plugins/products.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,126 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Store} from 'redux';
import {Client4} from 'mattermost-redux/client';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions';
import store from 'stores/redux_store';
import PluginRegistry from './registry';
export abstract class ProductPlugin {
abstract initialize(registry: PluginRegistry, store: Store): void;
abstract uninitialize(): void;
}
export function initializeProducts() {
return (dispatch: DispatchFunc) => {
return Promise.all([
dispatch(loadRemoteModules()),
dispatch(configureClient()),
]);
};
}
function configureClient() {
return (dispatch: DispatchFunc, getState: GetStateFunc) => {
const config = getConfig(getState());
Client4.setUseBoardsProduct(config.FeatureFlagBoardsProduct === 'true');
return Promise.resolve({data: true});
};
}
function loadRemoteModules() {
/* eslint-disable no-console */
return async (/*dispatch: DispatchFunc, getState: GetStateFunc*/) => {
// const config = getConfig(getState());
/**
* products contains a map of product IDs to a function that will load all of their parts. Calling that
* function will return an object where each field is a Promise that will resolve to that module.
*
* Note that these import paths must be statically defined or else they won't be found at runtime. They
* can't be constructed based on the name of a product at runtime.
*/
const products = [
{
id: 'boards',
load: () => ({
index: import('boards'),
// manifest: import('boards/manifest'),
}),
},
{
id: 'playbooks',
load: () => ({
index: import('playbooks'),
// manifest: import('boards/manifest'),
}),
},
];
await Promise.all(products.map(async (product) => {
if (!REMOTE_CONTAINERS[product.id]) {
console.log(`Product ${product.id} not found. Not loading it.`);
return;
}
console.log(`Loading product ${product.id}...`);
// Start loading the product
let imports;
try {
imports = product.load();
} catch (e) {
console.error(`Error loading ${product.id}`, e);
return;
}
// Wait for the individual parts to load
let index;
try {
index = await imports.index;
} catch (e) {
console.error(`Error loading index for ${product.id}`, e);
return;
}
// let manifest;
// try {
// manifest = await imports.manifest;
// } catch (e) {
// console.error(`Error loading manifest for ${product.id}`, e);
// return;
// }
// Initialize the previously loaded data
console.log(`Initializing product ${product.id}...`);
try {
initializeProduct(product.id, index.default);
} catch (e) {
console.error(`Error loading and initializing product ${product.id}`, e);
}
console.log(`Product ${product.id} initialized!`);
}));
return {data: true};
};
/* eslint-enable no-console */
}
function initializeProduct(id: string, Product: new () => ProductPlugin) {
const plugin = new Product();
const registry = new PluginRegistry(id);
plugin.initialize(registry, store);
}

1165
webapp/channels/src/plugins/registry.ts Обычный файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {Placement} from 'tippy.js';
import {setAutoShowLinkedBoardPreference} from 'mattermost-redux/actions/boards';
import {TourTip} from '@mattermost/components';
import {shouldShowAutoLinkedBoard} from 'selectors/plugins';
import {suitePluginIds} from 'utils/constants';
import {getPluggableId} from 'selectors/rhs';
import {PluginComponent} from 'types/store/plugins';
import {GlobalState} from 'types/store';
type Props = {
pulsatingDotPlacement?: Omit<Placement, 'auto'| 'auto-end'>;
}
const AutoShowLinkedBoardTourTip = ({
pulsatingDotPlacement = 'auto',
}: Props): JSX.Element | null => {
const dispatch = useDispatch();
const rhsPlugins: PluginComponent[] = useSelector((state: GlobalState) => state.plugins.components.RightHandSidebarComponent);
const pluggableId = useSelector(getPluggableId);
const pluginComponent = rhsPlugins.find((element: PluginComponent) => element.id === pluggableId);
const isBoards = pluginComponent && (pluginComponent.pluginId === suitePluginIds.focalboard || pluginComponent.pluginId === suitePluginIds.boards);
const showAutoLinkedBoard = useSelector(shouldShowAutoLinkedBoard);
const showAutoLinkedBoardTourTip = isBoards && showAutoLinkedBoard;
const title = (
<FormattedMessage
id='autoShowLinkedBoard.tutorialTip.title'
defaultMessage='Link kanban boards to channels'
/>
);
const screen = (
<FormattedMessage
id='autoShowLinkedBoard.tutorialTip.description'
defaultMessage='Manage tasks, plan sprints, conduct standup with the help of kanban boards and tables.'
/>
);
const handleDismiss = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
dispatch(setAutoShowLinkedBoardPreference());
}, []);
const handleOpen = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
dispatch(setAutoShowLinkedBoardPreference());
}, []);
const nextBtn = (
<FormattedMessage
id={'tutorial_tip.done'}
defaultMessage={'Done'}
/>
);
if (!showAutoLinkedBoardTourTip) {
return null;
}
return (
<TourTip
show={true}
screen={screen}
title={title}
overlayPunchOut={null}
placement='left-start'
pulsatingDotPlacement={pulsatingDotPlacement}
step={1}
singleTip={true}
showOptOut={false}
interactivePunchOut={true}
handleDismiss={handleDismiss}
handleOpen={handleOpen}
handlePrevious={handleDismiss}
pulsatingDotTranslate={{x: -10, y: 70}}
tippyBlueStyle={true}
hideBackdrop={true}
nextBtn={nextBtn}
handleNext={handleDismiss}
/>
);
};
export default AutoShowLinkedBoardTourTip;

Просмотреть файл

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {GlobalState} from 'types/store';
import {PluginComponent} from 'types/store/plugins';
import {getPluggableId} from 'selectors/rhs';
import RHSPlugin from './rhs_plugin';
function mapStateToProps(state: GlobalState) {
const rhsPlugins: PluginComponent[] = state.plugins.components.RightHandSidebarComponent;
const pluggableId = getPluggableId(state);
const pluginComponent = rhsPlugins.find((element: PluginComponent) => element.id === pluggableId);
const pluginTitle = pluginComponent ? pluginComponent.title : '';
return {
showPluggable: Boolean(pluginComponent),
pluggableId,
title: pluginTitle,
};
}
export default connect(mapStateToProps)(RHSPlugin);

Просмотреть файл

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import SearchResultsHeader from 'components/search_results_header';
import {BoardsTourTip, PlaybooksTourTip} from 'components/tours/worktemplate_explore_tour';
import Pluggable from 'plugins/pluggable';
import AutoShowLinkedBoardTourTip from './auto_show_linked_board_tourtip';
export type Props = {
showPluggable: boolean;
pluggableId: string;
title: React.ReactNode;
}
export default class RhsPlugin extends React.PureComponent<Props> {
render() {
const boardsTourTip = (<BoardsTourTip/>);
const playbooksTourtip = (<PlaybooksTourTip/>);
const autoLinkedBoardTourTip = (<AutoShowLinkedBoardTourTip/>);
return (
<div
id='rhsContainer'
className='sidebar-right__body'
>
<SearchResultsHeader>
{autoLinkedBoardTourTip}
{this.props.title}
</SearchResultsHeader>
{
this.props.showPluggable &&
<>
<Pluggable
pluggableName='RightHandSidebarComponent'
pluggableId={this.props.pluggableId}
/>
{boardsTourTip}
{playbooksTourtip}
</>
}
</div>
);
}
}

Просмотреть файл

@@ -0,0 +1,647 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`plugins/MainMenuActions should match snapshot in mobile view with some plugin and ability to click plugin 1`] = `
<div
aria-label="main menu"
className="a11y__popup Menu"
role="menu"
>
<ul
className="Menu__content dropdown-menu"
onClick={[Function]}
style={Object {}}
>
<MenuGroup>
<Connect(SystemPermissionGate)
permissions={
Array [
"sysconsole_write_billing",
]
}
>
<MenuCloudTrial
id="menuCloudTrial"
/>
</Connect(SystemPermissionGate)>
</MenuGroup>
<MenuGroup>
<Connect(SystemPermissionGate)
permissions={
Array [
"sysconsole_write_about_edition_and_license",
]
}
>
<MenuStartTrial
id="startTrial"
/>
</Connect(SystemPermissionGate)>
</MenuGroup>
<MenuGroup>
<MenuItemAction
icon={
<i
className="mentions"
>
@
</i>
}
id="recentMentions"
onClick={[Function]}
show={true}
text="Recent Mentions"
/>
<MenuItemAction
icon={
<i
className="fa fa-bookmark"
/>
}
id="flaggedPosts"
onClick={[Function]}
show={true}
text="Saved Posts"
/>
</MenuGroup>
<MenuGroup>
<MenuItemToggleModalRedux
dialogProps={
Object {
"isContentProductSettings": false,
}
}
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
icon={
<i
className="fa fa-user"
/>
}
id="profileSettings"
modalId="user_settings"
show={true}
text="Profile"
/>
<MenuItemToggleModalRedux
dialogProps={
Object {
"isContentProductSettings": true,
}
}
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
icon={
<i
className="fa fa-cog"
/>
}
id="accountSettings"
modalId="user_settings"
show={true}
text="Settings"
/>
</MenuGroup>
<MenuGroup>
<Connect(TeamPermissionGate)
permissions={
Array [
"manage_team",
]
}
teamId="someteamid"
>
<MenuItemToggleModalRedux
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
icon={
<i
className="fa fa-user-plus"
/>
}
id="addGroupsToTeam"
modalId="add_groups_to_team"
show={true}
text="Add Groups to Team"
/>
</Connect(TeamPermissionGate)>
<Connect(TeamPermissionGate)
permissions={
Array [
"add_user_to_team",
"invite_guest",
]
}
teamId="someteamid"
>
<MenuItemToggleModalRedux
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
extraText="Add people to the team"
icon={
<i
className="fa fa-user-plus"
/>
}
id="invitePeople"
modalId="invitation"
onClick={[Function]}
show={true}
text="Invite People"
/>
</Connect(TeamPermissionGate)>
</MenuGroup>
<MenuGroup>
<Connect(TeamPermissionGate)
permissions={
Array [
"manage_team",
]
}
teamId="someteamid"
>
<MenuItemToggleModalRedux
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
icon={
<i
className="fa fa-globe"
/>
}
id="teamSettings"
modalId="team_settings"
show={true}
text="Team Settings"
/>
</Connect(TeamPermissionGate)>
<Connect(TeamPermissionGate)
permissions={
Array [
"manage_team",
]
}
teamId="someteamid"
>
<MenuItemToggleModalRedux
dialogProps={
Object {
"teamID": "someteamid",
}
}
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
icon={
<i
className="fa fa-user-plus"
/>
}
id="manageGroups"
modalId="manage_team_groups"
show={true}
text="Manage Groups"
/>
</Connect(TeamPermissionGate)>
<Connect(TeamPermissionGate)
permissions={
Array [
"remove_user_from_team",
"manage_team_roles",
]
}
teamId="someteamid"
>
<MenuItemToggleModalRedux
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
icon={
<i
className="fa fa-users"
/>
}
id="manageMembers"
modalId="team_members"
show={true}
text="Manage Members"
/>
</Connect(TeamPermissionGate)>
<Connect(TeamPermissionGate)
invert={true}
permissions={
Array [
"remove_user_from_team",
"manage_team_roles",
]
}
teamId="someteamid"
>
<MenuItemToggleModalRedux
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
icon={
<i
className="fa fa-users"
/>
}
id="viewMembers"
modalId="team_members"
show={true}
text="View Members"
/>
</Connect(TeamPermissionGate)>
</MenuGroup>
<MenuGroup>
<Connect(SystemPermissionGate)
permissions={
Array [
"create_team",
]
}
>
<MenuItemLink
icon={
<i
className="fa fa-plus-square"
/>
}
id="createTeam"
show={true}
text="Create a Team"
to="/create_team"
/>
</Connect(SystemPermissionGate)>
<MenuItemLink
icon={
<i
className="fa fa-plus-square"
/>
}
id="joinTeam"
show={true}
text="Join Another Team"
to="/select_team"
/>
<MenuItemToggleModalRedux
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
icon={<LeaveTeamIcon />}
id="leaveTeam"
modalId="leave_team"
show={false}
text="Leave Team"
/>
</MenuGroup>
<MenuGroup>
<MenuItemAction
id="someplugin_pluginmenuitem"
key="someplugin_pluginmenuitem"
onClick={[Function]}
show={true}
text="some plugin text"
/>
</MenuGroup>
<MenuGroup>
<MenuItemLink
id="integrations"
show={false}
text="Integrations"
to="/somename/integrations"
/>
</MenuGroup>
<MenuGroup>
<MenuItemExternalLink
icon={
<i
className="fa fa-question"
/>
}
id="helpLink"
show={false}
text="Help"
/>
<MenuItemExternalLink
icon={
<i
className="fa fa-phone"
/>
}
id="reportLink"
show={false}
text="Report a Problem"
/>
<MenuItemExternalLink
icon={
<i
className="fa fa-mobile"
/>
}
id="nativeAppLink"
show={true}
text="Download Apps"
url=""
/>
<MenuItemToggleModalRedux
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
icon={
<i
className="fa fa-info"
/>
}
id="about"
modalId="about"
show={true}
text="About Mattermost"
/>
</MenuGroup>
<MenuGroup>
<MenuItemAction
icon={
<i
className="fa fa-sign-out"
/>
}
id="logout"
onClick={[Function]}
show={true}
text="Log Out"
/>
</MenuGroup>
</ul>
</div>
`;
exports[`plugins/MainMenuActions should match snapshot in web view 1`] = `
<div
aria-label="team menu"
className="a11y__popup Menu"
role="menu"
>
<ul
className="Menu__content dropdown-menu"
onClick={[Function]}
style={Object {}}
>
<MenuGroup>
<Connect(TeamPermissionGate)
permissions={
Array [
"manage_team",
]
}
teamId="someteamid"
>
<MenuItemToggleModalRedux
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
id="addGroupsToTeam"
modalId="add_groups_to_team"
show={true}
text="Add Groups to Team"
/>
</Connect(TeamPermissionGate)>
<Connect(TeamPermissionGate)
permissions={
Array [
"add_user_to_team",
"invite_guest",
]
}
teamId="someteamid"
>
<MenuItemToggleModalRedux
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
extraText="Add people to the team"
icon={false}
id="invitePeople"
modalId="invitation"
onClick={[Function]}
show={true}
text="Invite People"
/>
</Connect(TeamPermissionGate)>
<Connect(TeamPermissionGate)
permissions={
Array [
"manage_team",
]
}
teamId="someteamid"
>
<MenuItemToggleModalRedux
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
id="teamSettings"
modalId="team_settings"
show={true}
text="Team Settings"
/>
</Connect(TeamPermissionGate)>
<Connect(TeamPermissionGate)
permissions={
Array [
"manage_team",
]
}
teamId="someteamid"
>
<MenuItemToggleModalRedux
dialogProps={
Object {
"teamID": "someteamid",
}
}
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
id="manageGroups"
modalId="manage_team_groups"
show={true}
text="Manage Groups"
/>
</Connect(TeamPermissionGate)>
<Connect(TeamPermissionGate)
permissions={
Array [
"remove_user_from_team",
"manage_team_roles",
]
}
teamId="someteamid"
>
<MenuItemToggleModalRedux
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
id="manageMembers"
modalId="team_members"
show={true}
text="Manage Members"
/>
</Connect(TeamPermissionGate)>
<Connect(TeamPermissionGate)
invert={true}
permissions={
Array [
"remove_user_from_team",
"manage_team_roles",
]
}
teamId="someteamid"
>
<MenuItemToggleModalRedux
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
id="viewMembers"
modalId="team_members"
show={true}
text="View Members"
/>
</Connect(TeamPermissionGate)>
<MenuItemLink
id="joinTeam"
show={true}
text="Join Another Team"
to="/select_team"
/>
<MenuItemToggleModalRedux
className="destructive"
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
id="leaveTeam"
modalId="leave_team"
show={false}
text="Leave Team"
/>
</MenuGroup>
<MenuGroup>
<Connect(SystemPermissionGate)
permissions={
Array [
"create_team",
]
}
>
<MenuItemLink
className=""
disabled={false}
id="createTeam"
show={true}
sibling={false}
text="Create a Team"
to="/create_team"
/>
</Connect(SystemPermissionGate)>
</MenuGroup>
<MenuGroup>
<MenuItemAction
icon={false}
id="someplugin_pluginmenuitem"
key="someplugin_pluginmenuitem"
onClick={[Function]}
show={true}
text="some plugin text"
/>
</MenuGroup>
</ul>
</div>
`;

Просмотреть файл

@@ -0,0 +1,107 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`plugins/PostMessageView should match snapshot with extended post type 1`] = `
<PostMessageView
currentRelativeTeamUrl="team_url"
currentUser={
Object {
"username": "username",
}
}
emojis={
Object {
"name": "smile",
}
}
enableFormatting={true}
isRHS={false}
options={Object {}}
pluginPostTypes={
Object {
"testtype": Object {
"component": [Function],
},
}
}
post={
Object {
"id": "post_id",
"message": "this is some text",
"type": "testtype",
}
}
team={
Object {
"name": "team_name",
}
}
theme={
Object {
"id": "theme_id",
}
}
>
<PostTypePlugin
isRHS={false}
post={
Object {
"id": "post_id",
"message": "this is some text",
"type": "testtype",
}
}
theme={
Object {
"id": "theme_id",
}
}
>
<span
id="pluginId"
>
PostTypePlugin
</span>
</PostTypePlugin>
</PostMessageView>
`;
exports[`plugins/PostMessageView should match snapshot with no extended post type 1`] = `
<Connect(ShowMore)
checkOverflow={0}
text="this is some text"
>
<div
aria-readonly="true"
className="post-message__text"
dir="auto"
id="postMessageText_post_id"
onClick={[Function]}
tabIndex={0}
>
<Connect(PostMarkdown)
imageProps={
Object {
"onImageHeightChanged": [Function],
"onImageLoaded": [Function],
}
}
isRHS={false}
mentionKeys={Array []}
message="this is some text"
options={Object {}}
post={
Object {
"id": "post_id",
"message": "this is some text",
"type": "testtype",
}
}
/>
</div>
<Connect(Pluggable)
onHeightChange={[Function]}
pluggableName="PostMessageAttachment"
postId="post_id"
/>
</Connect(ShowMore)>
`;

Просмотреть файл

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import MainMenu from 'components/main_menu/main_menu';
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
describe('plugins/MainMenuActions', () => {
const pluginAction = jest.fn();
const requiredProps = {
teamId: 'someteamid',
teamType: '',
teamDisplayName: 'some name',
teamName: 'somename',
currentUser: {id: 'someuserid', roles: 'system_user'},
enableCommands: true,
enableCustomEmoji: true,
enableIncomingWebhooks: true,
enableOutgoingWebhooks: true,
enableOAuthServiceProvider: true,
canManageSystemBots: true,
enableUserCreation: true,
enableEmailInvitations: false,
enablePluginMarketplace: true,
showDropdown: true,
onToggleDropdown: () => {}, //eslint-disable-line no-empty-function
pluginMenuItems: [{id: 'someplugin', text: 'some plugin text', action: pluginAction}],
canCreateOrDeleteCustomEmoji: true,
canManageIntegrations: true,
moreTeamsToJoin: true,
guestAccessEnabled: true,
teamIsGroupConstrained: true,
teamUrl: '/team',
location: {
pathname: '/team',
},
actions: {
openModal: jest.fn(),
showMentions: jest.fn(),
showFlaggedPosts: jest.fn(),
closeRightHandSide: jest.fn(),
closeRhsMenu: jest.fn(),
getCloudLimits: jest.fn(),
},
isCloud: false,
isStarterFree: false,
subscription: {},
userIsAdmin: true,
isFirstAdmin: false,
canInviteTeamMember: false,
isFreeTrial: false,
teamsLimitReached: false,
usageDeltaTeams: -1,
};
test('should match snapshot in web view', () => {
let wrapper = shallowWithIntl(
<MainMenu
{...requiredProps}
/>,
);
wrapper = wrapper.shallow();
expect(wrapper).toMatchSnapshot();
expect(wrapper.findWhere((node) => node.key() === 'someplugin_pluginmenuitem')).toHaveLength(1);
});
test('should match snapshot in mobile view with some plugin and ability to click plugin', () => {
const props = {
...requiredProps,
mobile: true,
};
let wrapper = shallowWithIntl(
<MainMenu
{...props}
/>,
);
wrapper = wrapper.shallow();
expect(wrapper).toMatchSnapshot();
expect(wrapper.findWhere((node) => node.key() === 'someplugin_pluginmenuitem')).toHaveLength(1);
wrapper.findWhere((node) => node.key() === 'someplugin_pluginmenuitem').simulate('click');
expect(pluginAction).toBeCalled();
});
});

Просмотреть файл

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow, mount} from 'enzyme';
import PostMessageView from 'components/post_view/post_message_view/post_message_view';
class PostTypePlugin extends React.PureComponent {
render() {
return <span id='pluginId'>{'PostTypePlugin'}</span>;
}
}
describe('plugins/PostMessageView', () => {
const post = {type: 'testtype', message: 'this is some text', id: 'post_id'};
const pluginPostTypes = {
testtype: {component: PostTypePlugin},
};
const requiredProps = {
post,
pluginPostTypes,
currentUser: {username: 'username'},
team: {name: 'team_name'},
emojis: {name: 'smile'},
theme: {id: 'theme_id'},
enableFormatting: true,
currentRelativeTeamUrl: 'team_url',
};
test('should match snapshot with extended post type', () => {
const wrapper = mount(
<PostMessageView {...requiredProps}/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('#pluginId').text()).toBe('PostTypePlugin');
});
test('should match snapshot with no extended post type', () => {
const props = {...requiredProps, pluginPostTypes: {}};
const wrapper = shallow(
<PostMessageView {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import Textbox from 'components/textbox';
import PluginTextbox from '.';
describe('PluginTextbox', () => {
const baseProps = {
id: 'id',
channelId: 'channelId',
rootId: 'rootId',
tabIndex: -1,
value: '',
onChange: jest.fn(),
onKeyPress: jest.fn(),
createMessage: 'This is a placeholder',
supportsCommands: true,
characterLimit: 10000,
currentUserId: 'currentUserId',
currentTeamId: 'currentTeamId',
profilesInChannel: [],
autocompleteGroups: null,
actions: {
autocompleteUsersInChannel: jest.fn(),
autocompleteChannels: jest.fn(),
searchAssociatedGroupsForReference: jest.fn(),
},
useChannelMentions: true,
};
test('should rename suggestionListStyle to suggestionListPosition', () => {
const props: React.ComponentProps<typeof PluginTextbox> = {
...baseProps,
suggestionListStyle: 'bottom',
};
const wrapper = shallow(<PluginTextbox {...props}/>);
expect(wrapper.find(Textbox).prop('suggestionListPosition')).toEqual('bottom');
expect(wrapper.find(Textbox).prop('suggestionListStyle')).toBeUndefined();
});
});

Просмотреть файл

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import Textbox from 'components/textbox';
import BaseTextbox from 'components/textbox/textbox';
type Props = Omit<React.ComponentPropsWithRef<typeof Textbox>, 'suggestionListPosition'> & {
suggestionListStyle?: React.ComponentPropsWithRef<typeof Textbox>['suggestionListPosition'];
}
const PluginTextbox = React.forwardRef((props: Props, ref?: React.Ref<BaseTextbox>) => {
const {
suggestionListStyle,
...otherProps
} = props;
return (
<Textbox
ref={ref}
suggestionListPosition={suggestionListStyle}
{...otherProps}
/>
);
});
export default PluginTextbox;

Просмотреть файл

@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useSelector} from 'react-redux';
import {useProducts} from 'utils/products';
import {GlobalState} from 'types/store';
import {suitePluginIds} from 'utils/constants';
export const useGetPluginsActivationState = () => {
const pluginsList = useSelector((state: GlobalState) => state.plugins.plugins);
const pluginProducts = useProducts();
let boardsProductEnabled = false;
let playbooksProductEnabled = false;
if (pluginProducts) {
boardsProductEnabled = pluginProducts.some((product) => (product.pluginId === suitePluginIds.focalboard) || (product.pluginId === suitePluginIds.boards));
playbooksProductEnabled = pluginProducts.some((product) => product.pluginId === suitePluginIds.playbooks);
}
const boardsPlugin = pluginsList.focalboard;
const playbooksPlugin = pluginsList.playbooks;
return {boardsPlugin: Boolean(boardsPlugin), playbooksPlugin: Boolean(playbooksPlugin), boardsProductEnabled, playbooksProductEnabled};
};