Experimental implementation for webapp plugins (#7185)

* Start of experimental implementation for webapp plugins

* Updates to webapp plugin architecture

* Update pluggable test

* Remove debug code
This commit is contained in:
Joram Wilander
2017-08-29 09:54:02 -04:00
committed by GitHub
parent 82a8bd99cc
commit 257edc9ea3
15 changed files with 354 additions and 32 deletions

View File

@@ -2,6 +2,7 @@
// See License.txt for license information.
import ProfilePopover from 'components/profile_popover.jsx';
import Pluggable from 'plugins/pluggable';
import {Client4} from 'mattermost-redux/client';
import React from 'react';
@@ -79,13 +80,15 @@ export default class AtMention extends React.PureComponent {
placement='right'
rootClose={true}
overlay={
<ProfilePopover
user={user}
src={Client4.getProfilePictureUrl(user.id, user.last_picture_update)}
hide={this.hideProfilePopover}
isRHS={this.props.isRHS}
hasMention={this.props.hasMention}
/>
<Pluggable>
<ProfilePopover
user={user}
src={Client4.getProfilePictureUrl(user.id, user.last_picture_update)}
hide={this.hideProfilePopover}
isRHS={this.props.isRHS}
hasMention={this.props.hasMention}
/>
</Pluggable>
}
>
<a className='mention-link'>{'@' + user.username}</a>

View File

@@ -1,6 +1,8 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import ProfilePopover from './profile_popover.jsx';
import Pluggable from 'plugins/pluggable';
import * as Utils from 'utils/utils.jsx';
import PropTypes from 'prop-types';
@@ -56,16 +58,18 @@ export default class ProfilePicture extends React.Component {
placement='right'
rootClose={true}
overlay={
<ProfilePopover
user={this.props.user}
src={this.props.src}
status={this.props.status}
isBusy={this.props.isBusy}
hide={this.hideProfilePopover}
isRHS={this.props.isRHS}
hasMention={this.props.hasMention}
/>
}
<Pluggable>
<ProfilePopover
user={this.props.user}
src={this.props.src}
status={this.props.status}
isBusy={this.props.isBusy}
hide={this.hideProfilePopover}
isRHS={this.props.isRHS}
hasMention={this.props.hasMention}
/>
</Pluggable>
}
>
<span className='status-wrapper'>
<img

View File

@@ -2,6 +2,7 @@
// See License.txt for license information.
import ProfilePopover from './profile_popover.jsx';
import Pluggable from 'plugins/pluggable';
import * as Utils from 'utils/utils.jsx';
import {OverlayTrigger} from 'react-bootstrap';
@@ -76,15 +77,17 @@ export default class UserProfile extends React.Component {
placement='right'
rootClose={true}
overlay={
<ProfilePopover
user={this.props.user}
src={profileImg}
status={this.props.status}
isBusy={this.props.isBusy}
hide={this.hideProfilePopover}
isRHS={this.props.isRHS}
hasMention={this.props.hasMention}
/>
<Pluggable>
<ProfilePopover
user={this.props.user}
src={profileImg}
status={this.props.status}
isBusy={this.props.isBusy}
hide={this.hideProfilePopover}
isRHS={this.props.isRHS}
hasMention={this.props.hasMention}
/>
</Pluggable>
}
>
<div

View File

@@ -25,7 +25,7 @@
"localforage": "1.5.0",
"marked": "mattermost/marked#5194fc037b35036910c6542b04bb471fe56b27a9",
"match-at": "0.1.0",
"mattermost-redux": "mattermost/mattermost-redux#webapp-4.1",
"mattermost-redux": "mattermost/mattermost-redux#master",
"object-assign": "4.1.1",
"pdfjs-dist": "1.9.441",
"perfect-scrollbar": "0.7.1",

51
webapp/plugins/index.js Normal file
View File

@@ -0,0 +1,51 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
// EXPERIMENTAL - SUBJECT TO CHANGE
import store from 'stores/redux_store.jsx';
import {ActionTypes} from 'utils/constants.jsx';
import {getSiteURL} from 'utils/url.jsx';
window.plugins = {};
export function registerComponents(components) {
store.dispatch({
type: ActionTypes.RECEIVED_PLUGIN_COMPONENTS,
data: components || {}
});
}
export function initializePlugins() {
const pluginJson = window.mm_config.Plugins || '[]';
let pluginManifests;
try {
pluginManifests = JSON.parse(pluginJson);
} catch (error) {
console.error('Invalid plugins JSON: ' + error); //eslint-disable-line no-console
return;
}
pluginManifests.forEach((m) => {
function onLoad() {
// Add the plugin's js to the page
const script = document.createElement('script');
script.type = 'text/javascript';
script.text = this.responseText;
document.getElementsByTagName('head')[0].appendChild(script);
// Initialize the plugin
console.log('Registering ' + m.id + ' plugin...'); //eslint-disable-line no-console
const plugin = window.plugins[m.id];
plugin.initialize(registerComponents, store);
console.log('...done'); //eslint-disable-line no-console
}
// Fetch the plugin's bundled js
const xhrObj = new XMLHttpRequest();
xhrObj.open('GET', getSiteURL() + m.bundle_path, true);
xhrObj.addEventListener('load', onLoad);
xhrObj.send('');
});
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2017 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 Pluggable from './pluggable.jsx';
function mapStateToProps(state, ownProps) {
return {
...ownProps,
components: state.plugins.components,
theme: getTheme(state)
};
}
export default connect(mapStateToProps)(Pluggable);

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
// EXPERIMENTAL - SUBJECT TO CHANGE
import React from 'react';
import PropTypes from 'prop-types';
export default class Pluggable extends React.PureComponent {
static propTypes = {
/*
* Should be a single overridable React component
*/
children: PropTypes.element.isRequired,
/*
* Components for overriding provided by plugins
*/
components: PropTypes.object.isRequired,
/*
* Logged in user's theme
*/
theme: PropTypes.object.isRequired
}
render() {
const child = React.Children.only(this.props.children).type;
const components = this.props.components;
if (child == null) {
return null;
}
// Include any props passed to this component or to the child component
let props = {...this.props};
Reflect.deleteProperty(props, 'children');
Reflect.deleteProperty(props, 'components');
props = {...props, ...this.props.children.props};
// Override the default component with any registered plugin's component
if (components.hasOwnProperty(child.name)) {
const PluginComponent = components[child.name];
return (
<PluginComponent
{...props}
theme={this.props.theme}
/>
);
}
return React.cloneElement(this.props.children, {...props});
}
}

View File

@@ -2,7 +2,9 @@
// See License.txt for license information.
import views from './views';
import plugins from './plugins';
export default {
views
views,
plugins
};

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {combineReducers} from 'redux';
import {ActionTypes} from 'utils/constants.jsx';
function components(state = {}, action) {
switch (action.type) {
case ActionTypes.RECEIVED_PLUGIN_COMPONENTS: {
if (action.data) {
return {...action.data, ...state};
}
return state;
}
default:
return state;
}
}
export default combineReducers({
components
});

View File

@@ -14,6 +14,7 @@ import * as Websockets from 'actions/websocket_actions.jsx';
import {loadMeAndConfig} from 'actions/user_actions.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import * as I18n from 'i18n/i18n.jsx';
import {initializePlugins} from 'plugins';
// Import our styles
import 'bootstrap-colorpicker/dist/css/bootstrap-colorpicker.css';
@@ -90,6 +91,7 @@ function preRenderSetup(callwhendone) {
function afterIntl() {
$.when(d1).done(() => {
initializePlugins();
I18n.doAddLocaleData();
callwhendone();
});

View File

@@ -104,7 +104,7 @@ export default function configureStore(initialState) {
autoRehydrate: {
log: false
},
blacklist: ['errors', 'offline', 'requests', 'entities', 'views'],
blacklist: ['errors', 'offline', 'requests', 'entities', 'views', 'plugins'],
debounce: 500,
transforms: [
setTransformer

View File

@@ -0,0 +1,111 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`plugins/Pluggable should match snapshot with no overridden component 1`] = `
<IntlProvider>
<Pluggable
components={Object {}}
theme={Object {}}
>
<ProfilePopover
hasMention={false}
isRHS={false}
src="src"
theme={Object {}}
user={Object {}}
>
<Popover
bsClass="popover"
id="user-profile-popover"
placement="right"
theme={Object {}}
title="@undefined"
>
<div
className="popover right"
id="user-profile-popover"
role="tooltip"
style={
Object {
"display": "block",
"left": undefined,
"top": undefined,
}
}
theme={Object {}}
>
<div
className="arrow"
style={
Object {
"left": undefined,
"top": undefined,
}
}
/>
<h3
className="popover-title"
>
@undefined
</h3>
<div
className="popover-content"
>
<img
className="user-popover__image"
height="128"
src="src"
width="128"
/>
<div
className="popover__row first"
data-toggle="tooltip"
>
<a
className="text-nowrap text-lowercase user-popover__email"
href="#"
onClick={[Function]}
>
<i
className="fa fa-paper-plane"
/>
<FormattedMessage
defaultMessage="Send Message"
id="user_profile.send.dm"
values={Object {}}
>
<span>
Send Message
</span>
</FormattedMessage>
</a>
</div>
</div>
</div>
</Popover>
</ProfilePopover>
</Pluggable>
</IntlProvider>
`;
exports[`plugins/Pluggable should match snapshot with overridden component 1`] = `
<Pluggable
components={
Object {
"ProfilePopover": [Function],
}
}
theme={Object {}}
>
<ProfilePopoverPlugin
hasMention={false}
isRHS={false}
src="src"
theme={Object {}}
user={Object {}}
>
<span>
ProfilePopoverPlugin
</span>
</ProfilePopoverPlugin>
</Pluggable>
`;

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {mount} from 'enzyme';
import {IntlProvider} from 'react-intl';
import Pluggable from 'plugins/pluggable/pluggable.jsx';
import ProfilePopover from 'components/profile_popover.jsx';
class ProfilePopoverPlugin extends React.PureComponent {
render() {
return <span>{'ProfilePopoverPlugin'}</span>;
}
}
describe('plugins/Pluggable', () => {
test('should match snapshot with overridden component', () => {
const wrapper = mount(
<Pluggable
components={{ProfilePopover: ProfilePopoverPlugin}}
theme={{}}
>
<ProfilePopover
user={{}}
src='src'
/>
</Pluggable>
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with no overridden component', () => {
window.mm_config = {};
const wrapper = mount(
<IntlProvider>
<Pluggable
components={{}}
theme={{}}
>
<ProfilePopover
user={{}}
src='src'
/>
</Pluggable>
</IntlProvider>
);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -193,7 +193,9 @@ export const ActionTypes = keyMirror({
BROWSER_CHANGE_FOCUS: null,
EMOJI_POSTED: null
EMOJI_POSTED: null,
RECEIVED_PLUGIN_COMPONENTS: null
});
export const WebrtcActionTypes = keyMirror({

View File

@@ -5063,9 +5063,9 @@ math-expression-evaluator@^1.2.14:
version "1.2.16"
resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.16.tgz#b357fa1ca9faefb8e48d10c14ef2bcb2d9f0a7c9"
mattermost-redux@mattermost/mattermost-redux#webapp-4.1:
mattermost-redux@mattermost/mattermost-redux#master:
version "0.0.1"
resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/31bb5c2f21b504c4b7cab6624e4884bd3fc9f294"
resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/c5a9c96468cb8099230c447c87f2ca630bbfb531"
dependencies:
deep-equal "1.0.1"
harmony-reflect "1.5.1"