Thursday, May 28, 2015

Hiding Hippo Channel Manager toolbar when unnecessary

WARNING: The solution described in this article is applicable only to Hippo CMS v10.x. As Hippo CMS rewrote many parts of Channel Manager using Angular framework since v11, it is not applicable any more since v11.


In some use cases, content editors don't want to be distracted by the toolbar when editing a page in Hippo Channel Manager. In such use cases, they're okay with using Hippo Channel Manager just as a simple preview tool for the editing content.

So, it is not surprising to hear that they want the toolbar to be hidden in a project unless the current user is really a power user like the 'admin' user.
Yes, that should be easy. I'll look for possible configuration options or ask around on how to hide the toolbar based on the user.
Well, I initially expected that there should be a configuration option somewhere to show the toolbar only to some groups of users. That's why I said so. But, unfortunately, there's no option like that at the moment (at least until 7.9).

Actually someone suggested that I should hack around some CSS classes to hide it, but it would be really hard to set CSS classes properly based on the group memberships of the current user. Also, it sounds really hacky and unmaintainable, which I always try to avoid.

After digging in for a while, the following article took my sights:
After reading that article, it didn't take minutes for me to think about adding an invisible toolbar widget to do some JavaScript tricks to hide the whole toolbar. Right? That should be really an easy and maintainable solution!

I followed the guideline described in the article and was able to implement a solution which hides the whole toolbar unless the user is in the 'admin' group by default. Also, I even added a plugin configuration to be able to set which groups are allowed to see the toolbar.

Here's my plugin source:

// cms/src/main/java/com/example/cms/channelmanager/templatecomposer/ToolbarHidingPlugin.java

package com.example.cms.channelmanager.templatecomposer;

import java.text.MessageFormat;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;

import javax.jcr.NodeIterator;
import javax.jcr.RepositoryException;
import javax.jcr.query.Query;
import javax.jcr.query.QueryResult;

import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.wicket.Component;
import org.apache.wicket.markup.head.IHeaderResponse;
import org.apache.wicket.markup.head.JavaScriptHeaderItem;
import org.apache.wicket.request.resource.JavaScriptResourceReference;
import org.hippoecm.frontend.plugin.IPluginContext;
import org.hippoecm.frontend.plugin.config.IPluginConfig;
import org.hippoecm.frontend.session.UserSession;
import org.json.JSONException;
import org.json.JSONObject;
import org.onehippo.cms7.channelmanager.templatecomposer.ToolbarPlugin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.wicketstuff.js.ext.util.ExtClass;

/**
 * Invisible Channel Manager Page Editor toolbar widget plugin
 * in order to do some javascript trick like hiding the toolbar
 * based on user's group information.
 * <P>
 * By default, this plugin compares the group names of the current user
 * with the configured {@code groupNamesWithToolbarEnabled} group names.
 * 'admin' group is added to {@code groupNamesWithToolbarEnabled} by default.
 * If there's any common between both, this shows the toolbar.
 * Otherwise, this hides the toolbar.
 * </P>
 * @see http://www.onehippo.org/library/development/add-custom-button-to-the-template-composer-toolbar.html
 */
@ExtClass("Example.ChannelManager.ToolbarHidingPlugin")
public class ToolbarHidingPlugin extends ToolbarPlugin {

    private static Logger log = LoggerFactory.getLogger(ToolbarHidingPlugin.class);

    /**
     * Ext.js plugin JavaScript code.
     */
    private static final JavaScriptResourceReference TOOLBAR_HIDING_PLUGIN_JS =
        new JavaScriptResourceReference(ToolbarHidingPlugin.class, "ToolbarHidingPlugin.js");

    /**
     * JCR query statement to retrieve all the group names of the current user.
     */
    private static final String GROUPS_OF_USER_QUERY =
        "//element(*, hipposys:group)[(@hipposys:members = ''{0}'' or @hipposys:members = ''*'') and @hipposys:securityprovider = ''internal'']";

    /**
     * The names of the groups which the toolbar should be enabled to.
     */
    private Set<String> groupNamesWithToolbarEnabled = new HashSet<String>();

    public ToolbarHidingPlugin(IPluginContext context, IPluginConfig config) {
        super(context, config);

        String param = config.getString("group.names.with.toolbar.enabled", "admin");
        String [] groupNames = StringUtils.split(param, ",");

        if (ArrayUtils.isNotEmpty(groupNames)) {
            groupNamesWithToolbarEnabled.addAll(Arrays.asList(groupNames));
        }
    }

    @Override
    public void renderHead(final Component component, final IHeaderResponse response) {
        super.renderHead(component, response);
        response.render(JavaScriptHeaderItem.forReference(TOOLBAR_HIDING_PLUGIN_JS));
    }

    @Override
    protected JSONObject getProperties() throws JSONException {
        JSONObject properties = super.getProperties();

        if (groupNamesWithToolbarEnabled.contains("*")) {
            properties.put("toolbarEnabled", true);
        } else {
            Set<String> groupNames = getGroupNamesOfCurrentUser();
            Collection intersection = CollectionUtils.intersection(groupNames, groupNamesWithToolbarEnabled);
            properties.put("toolbarEnabled", CollectionUtils.isNotEmpty(intersection));
        }

        return properties;
    }

    private Set<String> getGroupNamesOfCurrentUser() {
        Set<String> groupNames = new HashSet<String>();

        try {
            final String username = UserSession.get().getJcrSession().getUserID();
            String statement = MessageFormat.format(GROUPS_OF_USER_QUERY, username);

            Query q = UserSession.get().getJcrSession().getWorkspace().getQueryManager().createQuery(statement, Query.XPATH);
            QueryResult result = q.execute();
            NodeIterator nodeIt = result.getNodes();
            String groupName;

            while (nodeIt.hasNext()) {
                groupName = nodeIt.nextNode().getName();
                groupNames.add(groupName);
            }
        } catch (RepositoryException e) {
            log.error("Failed to retrieve group names of the current user.", e);
        }

        return groupNames;
    }
}

Basically, the plugin class compares the group membership of the current user with the configured group names to which the toolbar should be enabled. And, it simply sets a flag value to the JSON properties in #getProperties() method. The JSON properties will be passed to the Ext.js class in the end.

Because Hippo Channel Manager components are mostly implemented in Ext.js as well, I need the following Ext.js class. This Ext.js class will read the flag variable passed from the plugin class and hide or show the toolbar HTML element.

// cms/src/main/resources/com/example/cms/channelmanager/templatecomposer/ToolbarHidingPlugin.js

Ext.namespace('Example.ChannelManager');

Example.ChannelManager.ToolbarHidingPlugin = Ext.extend(Ext.Container, {
  constructor: function(config) {

    // hide first and show if the current user has a group membership to which it is allowed.
    $('#pageEditorToolbar').hide();
    if (config.toolbarEnabled) {
      $('#pageEditorToolbar').show();
    }

    // show an empty invisible container widget.
    Example.ChannelManager.ToolbarHidingPlugin.superclass.constructor.call(this, Ext.apply(config, {
      width: 0,
      renderTo: Ext.getBody(),
      border: 0,
    }));
  }
});

I used a simple jQuery trick to hide/show the toolbar (#pageEditorToolbar):

  • $('#pageEditorToolbar').hide();
  • $('#pageEditorToolbar').show();

Now, I need to bootstrap this custom toolbar plugin into repository like the following:

<?xml version="1.0" encoding="UTF-8"?>

<!-- bootstrap/configuration/src/main/resources/configuration/frontend/hippo-channel-manager/templatecomposer-toolbar-hiding.xml -->

<sv:node sv:name="templatecomposer-toolbar-hiding" xmlns:sv="http://www.jcp.org/jcr/sv/1.0">
  <sv:property sv:name="jcr:primaryType" sv:type="Name">
    <sv:value>frontend:plugin</sv:value>
  </sv:property>
  <sv:property sv:name="plugin.class" sv:type="String">
    <sv:value>com.example.cms.channelmanager.templatecomposer.ToolbarHidingPlugin</sv:value>
  </sv:property>
  <sv:property sv:name="position.edit" sv:type="String">
    <sv:value>first</sv:value>
  </sv:property>
  <sv:property sv:name="position.view" sv:type="String">
    <sv:value>after template-composer-toolbar-pages-button</sv:value>
  </sv:property>
</sv:node>

Of course, the bootstrap XML should be added by a hippo:initializeitem in hippoecm-extension.xml like the following:

<!-- bootstrap/configuration/src/main/resources/hippoecm-extension.xml -->

    <!-- SNIP -->

    <sv:node sv:name="example-hippo-configuration-hippo-frontend-cms-hippo-channel-manager-templatecomposer-toolbar-hiding">
        <sv:property sv:name="jcr:primaryType" sv:type="Name">
            <sv:value>hippo:initializeitem</sv:value>
        </sv:property>
        <sv:property sv:name="hippo:sequence" sv:type="Double">
            <sv:value>30000.3</sv:value>
        </sv:property>
        <sv:property sv:name="hippo:contentresource" sv:type="String">
            <sv:value>configuration/frontend/hippo-channel-manager/templatecomposer-toolbar-hiding.xml</sv:value>
        </sv:property>
        <sv:property sv:name="hippo:contentroot" sv:type="String">
            <sv:value>/hippo:configuration/hippo:frontend/cms/hippo-channel-manager</sv:value>
        </sv:property>
        <sv:property sv:name="hippo:reloadonstartup" sv:type="Boolean">
            <sv:value>true</sv:value>
        </sv:property>
    </sv:node>

    <!-- SNIP -->
All right. That's it! Enjoy taming your Hippo!