diff options
-rw-r--r-- | webapp/components/at_mention/at_mention.jsx | 17 | ||||
-rw-r--r-- | webapp/components/profile_picture.jsx | 24 | ||||
-rw-r--r-- | webapp/components/user_profile.jsx | 21 | ||||
-rw-r--r-- | webapp/package.json | 2 | ||||
-rw-r--r-- | webapp/plugins/index.js | 51 | ||||
-rw-r--r-- | webapp/plugins/pluggable/index.js | 17 | ||||
-rw-r--r-- | webapp/plugins/pluggable/pluggable.jsx | 55 | ||||
-rw-r--r-- | webapp/reducers/index.js | 4 | ||||
-rw-r--r-- | webapp/reducers/plugins/index.js | 22 | ||||
-rw-r--r-- | webapp/root.jsx | 2 | ||||
-rw-r--r-- | webapp/store/index.js | 2 | ||||
-rw-r--r-- | webapp/tests/plugins/__snapshots__/pluggable.test.jsx.snap | 111 | ||||
-rw-r--r-- | webapp/tests/plugins/pluggable.test.jsx | 50 | ||||
-rw-r--r-- | webapp/utils/constants.jsx | 4 | ||||
-rw-r--r-- | webapp/yarn.lock | 4 |
15 files changed, 354 insertions, 32 deletions
diff --git a/webapp/components/at_mention/at_mention.jsx b/webapp/components/at_mention/at_mention.jsx index 9bb2d2aad..668222cc2 100644 --- a/webapp/components/at_mention/at_mention.jsx +++ b/webapp/components/at_mention/at_mention.jsx @@ -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> diff --git a/webapp/components/profile_picture.jsx b/webapp/components/profile_picture.jsx index fbaa46127..90cea9d34 100644 --- a/webapp/components/profile_picture.jsx +++ b/webapp/components/profile_picture.jsx @@ -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 diff --git a/webapp/components/user_profile.jsx b/webapp/components/user_profile.jsx index d4d900e6a..28b1e5bfb 100644 --- a/webapp/components/user_profile.jsx +++ b/webapp/components/user_profile.jsx @@ -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 diff --git a/webapp/package.json b/webapp/package.json index 8691f6abf..79731d1b4 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -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", diff --git a/webapp/plugins/index.js b/webapp/plugins/index.js new file mode 100644 index 000000000..2e8240cec --- /dev/null +++ b/webapp/plugins/index.js @@ -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(''); + }); +} diff --git a/webapp/plugins/pluggable/index.js b/webapp/plugins/pluggable/index.js new file mode 100644 index 000000000..d00f18a5d --- /dev/null +++ b/webapp/plugins/pluggable/index.js @@ -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); diff --git a/webapp/plugins/pluggable/pluggable.jsx b/webapp/plugins/pluggable/pluggable.jsx new file mode 100644 index 000000000..566e024e5 --- /dev/null +++ b/webapp/plugins/pluggable/pluggable.jsx @@ -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}); + } +} diff --git a/webapp/reducers/index.js b/webapp/reducers/index.js index ff2eb0d50..eb245d851 100644 --- a/webapp/reducers/index.js +++ b/webapp/reducers/index.js @@ -2,7 +2,9 @@ // See License.txt for license information. import views from './views'; +import plugins from './plugins'; export default { - views + views, + plugins }; diff --git a/webapp/reducers/plugins/index.js b/webapp/reducers/plugins/index.js new file mode 100644 index 000000000..9cad72715 --- /dev/null +++ b/webapp/reducers/plugins/index.js @@ -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 +}); diff --git a/webapp/root.jsx b/webapp/root.jsx index 635c8e93b..65d4cb0e4 100644 --- a/webapp/root.jsx +++ b/webapp/root.jsx @@ -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(); }); diff --git a/webapp/store/index.js b/webapp/store/index.js index 2b8a4fb28..2da472881 100644 --- a/webapp/store/index.js +++ b/webapp/store/index.js @@ -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 diff --git a/webapp/tests/plugins/__snapshots__/pluggable.test.jsx.snap b/webapp/tests/plugins/__snapshots__/pluggable.test.jsx.snap new file mode 100644 index 000000000..2f7a5e232 --- /dev/null +++ b/webapp/tests/plugins/__snapshots__/pluggable.test.jsx.snap @@ -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> +`; diff --git a/webapp/tests/plugins/pluggable.test.jsx b/webapp/tests/plugins/pluggable.test.jsx new file mode 100644 index 000000000..96dedb037 --- /dev/null +++ b/webapp/tests/plugins/pluggable.test.jsx @@ -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(); + }); +}); diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index ea6d1dba3..858ea6bbf 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -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({ diff --git a/webapp/yarn.lock b/webapp/yarn.lock index b3d9ba14b..b376d57d6 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -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" |