Customizing Liferay Navigation menus

How to create your own navigation menu element types in Liferay 7.1

In Liferay 7.1 we introduced a new approach to the Liferay Navigation. New navigation doesn't depend on the pages tree, there is a possibility to create multiple navigation menus, to compose them using different types of elements and to assign them different functions. Out of the box, we provide 3 types of menu elements - Pages, Submenus and URLs.  But we don't want to limit the users with these 3 types and any developer can create own navigation menu element type by implementing a simple interface and in this article, I want to explain how to do that. 
As the idea for this example, I want to use a "Call back request" functionality, there are plugins for most of the modern CMS which allow creating a "Call back request" button and process the user's input in a specific way. The idea of the article is to provide more or less the same functionality(of course, simplified) for the Liferay Navigation menus.
Let's create a module with the following structure:

The key parts here are CallbackSiteNavigationMenuItemType class which implements SiteNavigationMenuItemType interface, and the edit_callback. jsp view which provides the UI to add/edit navigation menu items of this type.

CallbackSiteNavigationMenuItemType has 4 main methods(getRegularURL, getTitle, renderAddPage, and renderEditPage), the first of them define the URL the specific menu item leads to. In the getRegularURL method, we create a PortletURL to perform a notification action with the userId from the properties of the SiteNavigationMenuItem instance and a number to callback to from the JS prompt function result:

@Override
public String getRegularURL(
   HttpServletRequest request,
   SiteNavigationMenuItem siteNavigationMenuItem) {

   UnicodeProperties properties = new UnicodeProperties(true);

   properties.fastLoad(siteNavigationMenuItem.getTypeSettings());

   PortletURL portletURL = PortletURLFactoryUtil.create(
      request, CallbackPortletKeys.CALLBACK, PortletRequest.ACTION_PHASE);

   portletURL.setParameter(
      ActionRequest.ACTION_NAME, "/callback/notify_user");
   portletURL.setParameter("userId", properties.getProperty("userId"));

   String numberParamName =
      _portal.getPortletNamespace(CallbackPortletKeys.CALLBACK) +
         "number";

   StringBundler sb = new StringBundler(7);

   sb.append("javascript: var number = prompt('");
   sb.append("Please specify a number to call back','');");
   sb.append("var url = Liferay.Util.addParams({");
   sb.append(numberParamName);
   sb.append(": number}, '");
   sb.append(portletURL.toString());
   sb.append("'); submitForm(document.hrefFm, url);");

   return sb.toString();
}

The getTitle method defines the title shown to the user in the menu, by default it uses the current SiteNavigationMenuItem name.
In the renderAddPage and renderEditPage methods, we use JSPRenderer to show the appropriate JSP to the user when adding or editing a navigation menu item of this new type.

Our view is also pretty simple:

<%@ include file="/init.jsp" %>

<%
SiteNavigationMenuItem siteNavigationMenuItem = 
   (SiteNavigationMenuItem)request.getAttribute(
      SiteNavigationWebKeys.SITE_NAVIGATION_MENU_ITEM);

String name = StringPool.BLANK;
User user = null;

if (siteNavigationMenuItem != null) {
   UnicodeProperties typeSettingsProperties = new UnicodeProperties();

   typeSettingsProperties.fastLoad(siteNavigationMenuItem.getTypeSettings());

   name = typeSettingsProperties.get("name");

   long userId = GetterUtil.getLong(typeSettingsProperties.get("userId"));

   user = UserLocalServiceUtil.getUser(userId);
}

CallbackDisplayContext callbackDisplayContext = new CallbackDisplayContext(
   renderRequest, renderResponse);
%>

<aui:input 
   label="name" 
   maxlength='<%= ModelHintsUtil.getMaxLength(
      SiteNavigationMenuItem.class.getName(), "name") %>' 
   name="TypeSettingsProperties--name--" 
   placeholder="name" value="<%= name %>">
   
   <aui:validator name="required" />
</aui:input>

<aui:input 
   name="TypeSettingsProperties--userId--" 
   type="hidden" value="<%= (user != null) ? user.getUserId() : 0 %>" />

<aui:input 
   disabled="<%= true %>" label="User to notify" 
   name="TypeSettingsProperties--userName--" placeholder="user-name" 
   value="<%= (user != null) ? user.getFullName() : StringPool.BLANK %>" />

<aui:button id="chooseUser" value="choose" />

<aui:script use="aui-base,liferay-item-selector-dialog">
   A.one('#<portlet:namespace/>chooseUser').on(
   'click',
   function(event) {
      var itemSelectorDialog = new A.LiferayItemSelectorDialog(
         {
            eventName: '<%= callbackDisplayContext.getEventName() %>',
            on: {
               selectedItemChange: function(event) {
                  if (event.newVal && event.newVal.length > 0) {
                     var user = event.newVal[0];

                     A.one('#<portlet:namespace/>userId').val(user.id);
                     A.one('#<portlet:namespace/>userName').val(user.name);
                  }

               }
            },
            'strings.add': 'Done',
            title: 'Choose User',
            url: '<%= callbackDisplayContext.getItemSelectorURL() %>'
         }
      );

      itemSelectorDialog.open();
   });
</aui:script>

We use TypeSettingsProperties-prefixed parameters names to save all necessary fields in the SiteNavigationMenuItem instance properties. We need to save two fields - name of the navigation menu item which is used as a title in the menu and the ID of the user we want to notify about a callback request, in a practical case it could be a sales manager/account executive or someone who holds the responsibility for this type of requests. This JSP is using Liferay Item Selector to select the user and the final UI looks like this:

Auxiliary functionality in this module is the notification functionality. We want to send a notification by email and using Liferay internal notification system. In order to do that we need two more components, an MVCActionCommand we used in our getRegularURL method and a notification handler to allow sending this type of notifications. NotifyUserMVCActionCommand has no magic, it accepts userId and number parameters from the request and send a notification to the user using SubscriptionSender component:

@Component(
   immediate = true,
   property = {
      "javax.portlet.name=" + CallbackPortletKeys.CALLBACK,
      "mvc.command.name=/callback/notify_user"
   },
   service = MVCActionCommand.class
)
public class NotifyUserMVCActionCommand extends BaseMVCActionCommand {

   @Override
   protected void doProcessAction(
         ActionRequest actionRequest, ActionResponse actionResponse)
      throws Exception {

      String number = ParamUtil.getString(actionRequest, "number");
      long userId = ParamUtil.getLong(actionRequest, "userId");

      SubscriptionSender subscriptionSender = _getSubscriptionSender(
         number, userId);

      subscriptionSender.flushNotificationsAsync();
   }

   private SubscriptionSender _getSubscriptionSender(
         String number, long userId)
      throws Exception {

      User user = _userLocalService.getUser(userId);

      SubscriptionSender subscriptionSender = new SubscriptionSender();

      subscriptionSender.setCompanyId(user.getCompanyId());
      subscriptionSender.setSubject("Call back request");
      subscriptionSender.setBody("Call back request: " + number);
      subscriptionSender.setMailId(StringUtil.randomId());
      subscriptionSender.setPortletId(CallbackPortletKeys.CALLBACK);
      subscriptionSender.setEntryTitle("Call bak request: " + number);

      subscriptionSender.addRuntimeSubscribers(
         user.getEmailAddress(), user.getFullName());

      return subscriptionSender;
   }

   @Reference
   private UserLocalService _userLocalService;

}

And to make it possible to send notifications from this particular portlet we need to implement a UserNotificationHandler to allow delivery and to define the body of notifications in this case:

@Component(
   immediate = true,
   property = "javax.portlet.name=" + CallbackPortletKeys.CALLBACK,
   service = UserNotificationHandler.class
)
public class CallbackUserNotificationHandler
   extends BaseModelUserNotificationHandler {

   public CallbackUserNotificationHandler() {
      setPortletId(CallbackPortletKeys.CALLBACK);
   }

   @Override
   public boolean isDeliver(
         long userId, long classNameId, int notificationType,
         int deliveryType, ServiceContext serviceContext)
      throws PortalException {

      return true;
   }

   @Override
   protected String getBody(
         UserNotificationEvent userNotificationEvent,
         ServiceContext serviceContext)
      throws Exception {

      JSONObject jsonObject = JSONFactoryUtil.createJSONObject(
         userNotificationEvent.getPayload());

      return jsonObject.getString("entryTitle");
   }

}

After deploying this module we can add the element of our new type to the navigation menu. Unfortunately, the only application display template for the navigation menus that supports Javascript in the URLs is List, so in order to try our Callback request element type, we have to configure Navigation Menu Widget to use List template. Clicking on the new item type we can type a number and request our Call back.

Please keep in mind that user cannot create a notification to himself so it is necessary to log out and perform this action as a Guest user or using any other account. In My Account section we can see the list of notifications:

Full code of this example is available here.

Hope it helps! If you need any help implementing your navigation menu item types  feel free to ask in comments.

Blogs

Thank you for this article!

 

But can somebody please answer the following question?

I've two aui:input fields:

 

<aui:input name="TypeSettingsProperties--userId--" value="<%= userId %>" /> and

<aui:input id="<portlet:namespace />userId" name="<portlet:namespace />userId" value="<%= userId %>" />

 

I don't understand why the script:

 

                  if (event.newVal && event.newVal.length > 0) {                      selectedUser = event.newVal[0];                      userName = selectedUser.name;                      userId = selectedUser.id;

                     A.all('#<portlet:namespace/>userId').val(userId);                      A.all('#<portlet:namespace/>userName').val(userName);                   }

 

only manipulates the first one?

What is this "TypeSettingsProperties--userId--" and why is this working at all?