Skip to content
This repository has been archived by the owner on Mar 13, 2024. It is now read-only.

Commit

Permalink
Adding admin console plugins (#2794)
Browse files Browse the repository at this point in the history
* Adding admin console plugins

* Adding some unit tests

* Fixing tests

* Fixing typos and mark the register function as low level

* Moving the admin definitions selector to redux

* Some extra fixes

* Fixing tests

* Addressing PR comments

* Update plugins/index.js

Co-Authored-By: Jesse Hallam <[email protected]>

* Fixing tests

* Addressing PR review comments

* Fixing linter

* Fixing linter and tests
  • Loading branch information
jespino authored and lieut-data committed Sep 2, 2019
1 parent aa9c297 commit fa23e39
Show file tree
Hide file tree
Showing 19 changed files with 230 additions and 15 deletions.
23 changes: 23 additions & 0 deletions actions/admin_actions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,26 @@ export async function testSmtp(success, error) {
error({id: err.server_error_id, ...err});
}
}

export function registerAdminConsolePlugin(pluginId, reducer) {
return (storeDispatch) => {
storeDispatch({
type: ActionTypes.RECEIVED_ADMIN_CONSOLE_REDUCER,
data: {
pluginId,
reducer,
},
});
};
}

export function unregisterAdminConsolePlugin(pluginId) {
return (storeDispatch) => {
storeDispatch({
type: ActionTypes.REMOVED_ADMIN_CONSOLE_REDUCER,
data: {
pluginId,
},
});
};
}
39 changes: 39 additions & 0 deletions actions/admin_actions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import * as Actions from 'actions/admin_actions.jsx';
import configureStore from 'store';

describe('Actions.Admin', () => {
let store;
beforeEach(async () => {
store = await configureStore();
});

test('Register a plugin adds the plugin to the state', async () => {
const func = jest.fn();
expect(store.getState().plugins.adminConsoleReducers).toEqual({});
store.dispatch(Actions.registerAdminConsolePlugin('plugin-id', func));
expect(store.getState().plugins.adminConsoleReducers).toEqual({'plugin-id': func});
});

test('Unregister a plugin removes an existing plugin from the state', async () => {
const func = jest.fn();
expect(store.getState().plugins.adminConsoleReducers).toEqual({});
store.dispatch(Actions.registerAdminConsolePlugin('plugin-id', func));
expect(store.getState().plugins.adminConsoleReducers).toEqual({'plugin-id': func});

store.dispatch(Actions.unregisterAdminConsolePlugin('plugin-id'));
expect(store.getState().plugins.adminConsoleReducers).toEqual({});
});

test('Unregister an unexisting plugin do nothing', async () => {
const func = jest.fn();
expect(store.getState().plugins.adminConsoleReducers).toEqual({});
store.dispatch(Actions.registerAdminConsolePlugin('plugin-id', func));
expect(store.getState().plugins.adminConsoleReducers).toEqual({'plugin-id': func});

store.dispatch(Actions.unregisterAdminConsolePlugin('invalid-plugin-id'));
expect(store.getState().plugins.adminConsoleReducers).toEqual({'plugin-id': func});
});
});
2 changes: 1 addition & 1 deletion actions/websocket_actions.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ describe('handlePluginEnabled/handlePluginDisabled', () => {
// Assert handlePluginDisabled is idempotent
handlePluginDisabled({data: {manifest}});

expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledTimes(2);
const dispatchRemovedArg = store.dispatch.mock.calls[0][0];
expect(dispatchRemovedArg.type).toBe(ActionTypes.REMOVED_WEBAPP_PLUGIN);
expect(dispatchRemovedArg.data).toBe(manifest);
Expand Down
4 changes: 2 additions & 2 deletions components/admin_console/admin_console.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ import SchemaAdminSettings from 'components/admin_console/schema_admin_settings'
import DiscardChangesModal from 'components/discard_changes_modal.jsx';

import AdminSidebar from './admin_sidebar';
import AdminDefinition from './admin_definition';
import Highlight from './highlight';

export default class AdminConsole extends React.Component {
static propTypes = {
config: PropTypes.object.isRequired,
adminDefinition: PropTypes.object.isRequired,
environmentConfig: PropTypes.object,
license: PropTypes.object.isRequired,
buildEnterpriseReady: PropTypes.bool,
Expand Down Expand Up @@ -72,7 +72,7 @@ export default class AdminConsole extends React.Component {
}

renderRoutes = (extraProps) => {
const schemas = Object.values(AdminDefinition).reduce((acc, section) => {
const schemas = Object.values(this.props.adminDefinition).reduce((acc, section) => {
const items = Object.values(section).filter((item) => {
if (item.isHidden && item.isHidden(this.props.config, {}, this.props.license, this.props.buildEnterpriseReady)) {
return false;
Expand Down
2 changes: 2 additions & 0 deletions components/admin_console/admin_console.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import React from 'react';
import {shallow} from 'enzyme';

import AdminConsole from 'components/admin_console/admin_console';
import AdminDefinition from 'components/admin_console/admin_definition';

describe('components/AdminConsole', () => {
const baseProps = {
Expand All @@ -14,6 +15,7 @@ describe('components/AdminConsole', () => {
RestrictSystemAdmin: false,
},
},
adminDefinition: AdminDefinition,
license: {},
buildEnterpriseReady: true,
match: {
Expand Down
4 changes: 3 additions & 1 deletion components/admin_console/admin_definition.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export const it = {
licensedForFeature: (feature) => (config, state, license) => license.IsLicensed && license[feature] === 'true',
};

export default {
const AdminDefinition = {
about: {
icon: 'fa-info-circle',
sectionTitle: t('admin.sidebar.about'),
Expand Down Expand Up @@ -4307,3 +4307,5 @@ t('admin.field_names.postEditTimeLimit');
t('admin.field_names.restrictCreationToDomains');
t('admin.field_names.restrictDirectMessage');
t('admin.field_names.teammateNameDisplay');

export default AdminDefinition;
2 changes: 1 addition & 1 deletion components/admin_console/admin_definition.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ var definition = yup.object().shape({
});

describe('components/admin_console/admin_definition', () => {
test('should pass all validations checks', () => {
it('should pass all validations checks', () => {
definition.strict().validateSync(adminDefinition);
});
});
8 changes: 4 additions & 4 deletions components/admin_console/admin_sidebar/admin_sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {browserHistory} from 'utils/browser_history';
import AdminSidebarCategory from 'components/admin_console/admin_sidebar_category.jsx';
import AdminSidebarHeader from 'components/admin_console/admin_sidebar_header';
import AdminSidebarSection from 'components/admin_console/admin_sidebar_section.jsx';
import AdminDefinition from 'components/admin_console/admin_definition.jsx';
import Highlight from 'components/admin_console/highlight.jsx';
import SearchIcon from 'components/widgets/icons/search_icon.jsx';

Expand All @@ -30,6 +29,7 @@ export default class AdminSidebar extends React.Component {
license: PropTypes.object.isRequired,
config: PropTypes.object,
plugins: PropTypes.object,
adminDefinition: PropTypes.object,
buildEnterpriseReady: PropTypes.bool,
siteName: PropTypes.string,
onFilterChange: PropTypes.func.isRequired,
Expand Down Expand Up @@ -92,7 +92,7 @@ export default class AdminSidebar extends React.Component {
}

if (this.idx === null) {
this.idx = generateIndex(this.context.intl);
this.idx = generateIndex(this.props.adminDefinition, this.context.intl);
}
let query = '';
for (const term of filter.split(' ')) {
Expand Down Expand Up @@ -147,7 +147,7 @@ export default class AdminSidebar extends React.Component {
return true;
};
const result = new Set();
for (const section of Object.values(AdminDefinition)) {
for (const section of Object.values(this.props.adminDefinition)) {
for (const item of Object.values(section)) {
if (isVisible(item)) {
result.add(item.url);
Expand Down Expand Up @@ -320,7 +320,7 @@ export default class AdminSidebar extends React.Component {
</OverlayTrigger>
</div>}
</li>
{this.renderRootMenu(AdminDefinition)}
{this.renderRootMenu(this.props.adminDefinition)}
</ul>
</Highlight>
</div>
Expand Down
6 changes: 6 additions & 0 deletions components/admin_console/admin_sidebar/admin_sidebar.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {IntlProvider} from 'react-intl';
import {shallow} from 'enzyme';

import AdminSidebar from 'components/admin_console/admin_sidebar/admin_sidebar.jsx';
import AdminDefinition from 'components/admin_console/admin_definition';

jest.mock('utils/utils', () => {
const original = require.requireActual('utils/utils');
Expand All @@ -29,6 +30,7 @@ describe('components/AdminSidebar', () => {
EnableUploads: true,
},
},
adminDefinition: AdminDefinition,
buildEnterpriseReady: false,
navigationBlocked: false,
siteName: 'test snap',
Expand Down Expand Up @@ -72,6 +74,7 @@ describe('components/AdminSidebar', () => {
EnableUploads: true,
},
},
adminDefinition: AdminDefinition,
buildEnterpriseReady: false,
siteName: 'test snap',
navigationBlocked: false,
Expand Down Expand Up @@ -113,6 +116,7 @@ describe('components/AdminSidebar', () => {
EnableUploads: true,
},
},
adminDefinition: AdminDefinition,
buildEnterpriseReady: false,
siteName: 'test snap',
navigationBlocked: false,
Expand Down Expand Up @@ -152,6 +156,7 @@ describe('components/AdminSidebar', () => {
EnableUploads: true,
},
},
adminDefinition: AdminDefinition,
buildEnterpriseReady: true,
navigationBlocked: false,
siteName: 'test snap',
Expand Down Expand Up @@ -209,6 +214,7 @@ describe('components/AdminSidebar', () => {
EnableUploads: true,
},
},
adminDefinition: AdminDefinition,
buildEnterpriseReady: true,
navigationBlocked: false,
siteName: 'test snap',
Expand Down
3 changes: 3 additions & 0 deletions components/admin_console/admin_sidebar/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {getPlugins} from 'mattermost-redux/actions/admin';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';

import {getNavigationBlocked} from 'selectors/views/admin';
import {getAdminDefinition} from 'selectors/admin_console';

import AdminSidebar from './admin_sidebar.jsx';

Expand All @@ -15,6 +16,7 @@ function mapStateToProps(state) {
const config = getConfig(state);
const buildEnterpriseReady = config.BuildEnterpriseReady === 'true';
const siteName = config.SiteName;
const adminDefinition = getAdminDefinition(state);

return {
license,
Expand All @@ -23,6 +25,7 @@ function mapStateToProps(state) {
navigationBlocked: getNavigationBlocked(state),
buildEnterpriseReady,
siteName,
adminDefinition,
};
}

Expand Down
3 changes: 3 additions & 0 deletions components/admin_console/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/user

import {setNavigationBlocked, deferNavigation, cancelNavigation, confirmNavigation} from 'actions/admin_actions.jsx';
import {getNavigationBlocked, showNavigationPrompt} from 'selectors/views/admin';
import {getAdminDefinition} from 'selectors/admin_console';

import AdminConsole from './admin_console.jsx';

function mapStateToProps(state) {
const generalConfig = getGeneralConfig(state);
const buildEnterpriseReady = generalConfig.BuildEnterpriseReady === 'true';
const adminDefinition = getAdminDefinition(state);

return {
config: Selectors.getConfig(state),
Expand All @@ -29,6 +31,7 @@ function mapStateToProps(state) {
showNavigationPrompt: showNavigationPrompt(state),
isCurrentUserSystemAdmin: isCurrentUserSystemAdmin(state),
roles: getRoles(state),
adminDefinition,
};
}

Expand Down
2 changes: 2 additions & 0 deletions plugins/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {getSiteURL} from 'utils/url.jsx';
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';

// 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.
Expand Down Expand Up @@ -151,6 +152,7 @@ export function removePlugin(manifest) {
}
unregisterAllPluginWebSocketEvents(manifest.id);
unregisterPluginReconnectHandler(manifest.id);
store.dispatch(unregisterAdminConsolePlugin(manifest.id));
unregisterPluginTranslationsSource(manifest.id);
const script = document.getElementById('plugin_' + manifest.id);
if (!script) {
Expand Down
22 changes: 22 additions & 0 deletions plugins/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import {
registerPluginTranslationsSource,
} from 'actions/views/root';

import {
registerAdminConsolePlugin,
unregisterAdminConsolePlugin,
} from 'actions/admin_actions';

import store from 'stores/redux_store.jsx';
import {ActionTypes} from 'utils/constants.jsx';
import {generateId} from 'utils/utils.jsx';
Expand Down Expand Up @@ -466,6 +471,23 @@ export default class PluginRegistry {
store.dispatch(registerPluginTranslationsSource(this.id, getTranslationsForLocale));
}

// Register a admin console definitions override function (This is a
// low-level interface and can change in the future).
// - func - A function that recieve the admin console config
// definitions and return a new version of it, which is used for build the
// admin console.
// Each plugin can only register a function, if the plugin register
// multiple functions the last one will be used.
registerAdminConsolePlugin(func) {
store.dispatch(registerAdminConsolePlugin(this.id, func));
}

// Unregister a previously registered admin console definition override function.
// Returns undefined.
unregisterAdminConsolePlugin() {
store.dispatch(unregisterAdminConsolePlugin(this.id));
}

// Register a Right-Hand Sidebar component by providing a title for the right hand component.
// Accepts the following:
// - title - A string or JSX element to display as a title for the RHS.
Expand Down
35 changes: 35 additions & 0 deletions reducers/plugins/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,37 @@ function postCardTypes(state = {}, action) {
}
}

function adminConsoleReducers(state = {}, action) {
switch (action.type) {
case ActionTypes.RECEIVED_ADMIN_CONSOLE_REDUCER: {
if (action.data) {
const nextState = {...state};
nextState[action.data.pluginId] = action.data.reducer;
return nextState;
}
return state;
}
case ActionTypes.REMOVED_ADMIN_CONSOLE_REDUCER: {
if (action.data) {
const nextState = {...state};
delete nextState[action.data.pluginId];
return nextState;
}
return state;
}
case ActionTypes.RECEIVED_WEBAPP_PLUGIN:
case ActionTypes.REMOVED_WEBAPP_PLUGIN:
if (action.data) {
const nextState = {...state};
delete nextState[action.data.id];
return nextState;
}
return state;
default:
return state;
}
}

export default combineReducers({

// object where every key is a plugin id and values are webapp plugin manifests
Expand All @@ -224,4 +255,8 @@ export default combineReducers({
// object where every key is a post type and the values are components wrapped in an
// an object that contains a plugin id
postCardTypes,

// object where every key is a plugin id and the value is a function that
// modifies the admin console definition data structure
adminConsoleReducers,
});
19 changes: 19 additions & 0 deletions selectors/admin_console.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {createSelector} from 'reselect';
import {cloneDeep} from 'lodash';

import AdminDefinition from 'components/admin_console/admin_definition.jsx';

export const getAdminDefinition = createSelector(
() => AdminDefinition,
(state) => state.plugins.adminConsoleReducers,
(adminDefinition, reducers) => {
let result = cloneDeep(AdminDefinition);
for (const reducer of Object.values(reducers)) {
result = reducer(result);
}
return result;
}
);
Loading

0 comments on commit fa23e39

Please sign in to comment.