Using Navigation Entry for custom functionality in Liferay core views

By Rafał Pydyniak | 2023-10-30

In some projects there is a need to override some functionalities provided by Liferay out of the box. There are multiple ways of achieving what we need and they are described in Liferay documentation. This extensibility is, in my humble opinion, one of the biggest assets of Liferay platform. Today I would like to share how can you avoid using OSGi fragments for JSP overrides in some cases by using custom Navigation Entries which can save you a lot of work during Liferay version updates.

Background

First lets describe some background and lets find an answer to following questions:

What are OSGi Fragments

OSGi fragment is a feature which can be used to extend or modify the existing functionality without modifying the code of original bundle. In Liferay context OSGi fragments can be used for example to override the complete JSP file from Liferay sources. Overriding JSPs is the most common use case for OSGi fragments in Liferay but not the only one. They can also be used for other things like exporting internal package which sometimes can be super handy.

Why should you avoid using fragments for JSP overrides and why they are an issue while upgrading your Liferay version

The overriding of JSPs is powerful but comes with cost. The main issue with this approach is that when you upgrade Liferay you basically have to verify every single overridden JSP and check if it is still working. In many cases it won't because:

  • Other JSPs which you didn't override changed and there are some dependencies between them
  • API changed: methods, or even classes, used in JSP do not exist anymore
  • Version mentioned in fragment host changed and your fragment does no longer attach to the overridden bundle

Moreover, you might easily miss that something is not working because JSPs generally do not produce compile errors and you can not rely on IDE as well as it will generally show a lot of errors in overridden JSPs anyway (because of dependencies which IDE does not know).

Even if you are not upgrading the fragments can sometimes be tricky and confusing, for example they can stop working without any errors on startup or during runtime. I even wrote why this could happen some time ago and how custom system checkers can help for such cases

Of course if you have one or two JSPs overrides it's fine. The issue is when you have dozens of them.

Why would you even use OSGi fragments for JSP overrides in real life projects

To override JSPs of course ;) This is actually really handy in many occasions where Liferay has some out of the box solution which only slightly does not fit to your use case. Some real examples from projects I've worked on:

  • Modification of login portlet to change redirection behaviour and add new fields/validations to registration form
  • Custom fields in addresses form and custom permissions handling
  • Adding custom field in Liferay Commerce Channel to describe what is the country of origin of channel for tax purposes

In fact this functionality can be used in many parts of Liferay code portlets. Both Liferay Portal and Liferay Commerce ones.

So what are navigation entries, the "heroes" of this post and screen navigation in general. Long story short - Screen Navigation framework allows you to create custom navigations for your application. In JSP it can be used with liferay-frontend:screen-navigation taglib. For examples you can of course look into the Liferay code where there are dozens of them. For example:

<liferay-frontend:screen-navigation
	context="<%= commerceCountriesDisplayContext.getCountry() %>"
	key="<%= CommerceCountryScreenNavigationConstants.SCREEN_NAVIGATION_KEY_COMMERCE_COUNTRY_GENERAL %>"
	portletURL="<%= currentURLObj %>"
/>

There is also a Liferay documentation describing screen navigation framework

How and when Navigation Entries can be used as OSGi fragment replacement

So as mentioned the Screen Navigation is used in dozens of places in Liferay core code. We can take advantage of that and in use cases where we need to add new code (new fields to a form for example) we can define new Screen Navigation Entry instead of overriding the whole JSP.

This approach has some great advantages:

  • It is much easier to upgrade Liferay code - in most cases you will not need to do anything!
  • These are standard Liferay modules for which you will get normal runtime errors
  • Easier to find dependencies issues like using internal package, as there will be more verbose errors in logs

Going through the code

If you would like to use this approach you start exactly as you would like to find a JSP to override. My proposal is to check what Portlet is used on a page, then find it in sources and then figure out which JSPs you would like to override. Instead of overriding JSP with OSGi fragment though you will want to find out perhaps there is some navigation to which you could add your custom entry.

Let's take a real example. In one of the projects I've worked on there was a need to add a custom field to country. We needed to gather some extra information about each country. For the showcase purpose lets assume it is one field "EU country" which is needed to distinguish EU countries for tax purposes.

If you look into the portal (Commerce -> Countries) you will see that there is no way of defining that by default:

Country Details Page
Country Details Page

So we need to find out which portlet is that. Just looking at the URL you can tell this is CommerceCountryPortlet. Next step would be to look into the Liferay sources where you can find all related JSP files:

Countries Portlet JSP files
Countries Portlet JSP files
In the screenshot above you can see three JSPs used for navigation in country details portlet:
Countries Details
Countries Details

Now we need to add new field. One approach would be to override the details.jsp with OSGi fragment with all the disadvantages mentioned before. Another approach is to see how is the navigation menu created and if we can extend it. In this case (any most of them) the Screen Navigation framework is used which we can see in edit_commerce_country.jsp

Screen Navigation in edit_commerce_country.jsp
Screen Navigation in edit_commerce_country.jsp

Once we found a screen navigation we know that instead of overriding the whole JSP we can simply add new menu entry for additional fields we need.

The steps to do that are:

  1. Create new Liferay module
  2. Make sure to define Web-ContextPath in bnd.bnd file.
  3. Add custom menu entry you will need two components: navigation category and navigation entry
  4. Add JSP which should be rendered
  5. Then of course we need to save our data somehow so we will need a MVC action command

Creating new module is something you probably know how to do but lets go through steps 2-5

Adding Web-ContextPath

This is really straightforward but if you have never done that before you might not know what to do here. Simply this is only one line which you need to add in bnd.bnd file of your module: Web-ContextPath: /country-custom-settings and it will define a ContextPath for the bundle rendering purposes. It is required as otherwise we won't be able to include our custom JSP.

Navigation category and navigation entry

Next step is to define two classes and mark them as OSGi components. First one is the implementation of ScreenNavigationCategory which defines a category in screen navigation. The class we need is really simple:

@Component(
        property = "screen.navigation.category.order:Integer=100",
        service = ScreenNavigationCategory.class
)
public class CountryCustomSettingsScreenNavigationCategory
        implements ScreenNavigationCategory {
    @Override
    public String getCategoryKey() {
        return "pydyniak-country-custom-settings";
    }

    @Override
    public String getLabel(Locale locale) {
        return LanguageUtil.get(locale, "custom-settings");
    }

    @Override
    public String getScreenNavigationKey() {
        return "commerce.country.general";
    }
}

Three methods we use here are:

  • getCategoryKey: used to define unique key for our category
  • getLabel: label used in navigation menu
  • getScreenNavigationKey: defines the key of screen navigation for which we want to add the entry. It has to be the same as the key defined in liferay-frontend:screen-navigation

Next we need an implementation of ScreenNavigationEntry. It has some more code which can be found in full version on my github

Here are the crucial parts:

@Component(
	property = "screen.navigation.entry.order:Integer=10",
	service = ScreenNavigationEntry.class
)
public class CountryCustomSettingsScreenNavigationEntry
	extends CountryCustomSettingsScreenNavigationCategory
	implements ScreenNavigationEntry<Country> {
    
	}

We are defining the navigation entry and marking it as OSGi component. The screen.navigation.entry.order:Integer can be used to define the order of navigation entries

@Override
	public void render(
			HttpServletRequest httpServletRequest,
			HttpServletResponse httpServletResponse)
		throws IOException {

		long countryId = ParamUtil.get(httpServletRequest, "countryId", 0L);
		Country country = countryService.fetchCountry(countryId);
		long companyId = portal.getCompanyId(httpServletRequest);
		setupEuCountryExpando(companyId);
		boolean euCountry = (boolean) country.getExpandoBridge().getAttribute(CountryCustomSettingsConstants.EU_COUNTRY_EXPANDO_KEY);

		httpServletRequest.setAttribute(CountryCustomSettingsConstants.PARAM_COUNTRY_ID, countryId);
		httpServletRequest.setAttribute(CountryCustomSettingsConstants.EU_COUNTRY_EXPANDO_KEY, euCountry);
		jspRenderer.renderJSP(servletContext, httpServletRequest, httpServletResponse, "/pydyniak/country_custom_settings_view.jsp");
	}

This method renders our custom JSP. Please note that I've used expando bridge framework to store the custom boolean value. The method "setupEuCountryExpando" source can be seen below:

private void setupEuCountryExpando(long companyId) {
		try {
			ExpandoBridge expandoBridge = expandoBridgeFactory.getExpandoBridge(companyId, Country.class.getName());
			if (!expandoBridge.hasAttribute(CountryCustomSettingsConstants.EU_COUNTRY_EXPANDO_KEY)) {
				expandoBridge.addAttribute(CountryCustomSettingsConstants.EU_COUNTRY_EXPANDO_KEY, ExpandoColumnConstants.BOOLEAN,
						false, false);
			}
		}catch (PortalException ex) {
			LOG.error("Error while setting up eu country expando", ex);
		}
	}

and it sets up the expando for Country. If it already exists then nothing changes.

Custom JSP

The render method from previous point mentions the JSP which is just a simple JSP (some parts skipped, see Github for full code):

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

<%
    long countryId = (long) request.getAttribute(CountryCustomSettingsConstants.PARAM_COUNTRY_ID);
    boolean euCountry = (boolean) request.getAttribute(CountryCustomSettingsConstants.EU_COUNTRY_EXPANDO_KEY);
%>
<portlet:actionURL name="/commerce_country/edit_country_custom_settings"
                   var="editCountryCustomSettingsActionURL"/>

<aui:form action="<%= editCountryCustomSettingsActionURL %>" cssClass="container-fluid container-fluid-max-xl"
          method="post" name="fm">
    <div class="lfr-form-content">
        <div class="sheet">
            <div class="panel-group panel-group-flush">
                <aui:fieldset>
                    <aui:input name="redirect" type="hidden" value="<%= PortalUtil.getCurrentURL(request) %>"/>
                    <aui:input name="countryId" type="hidden" value="<%= countryId %>"/>
                    <aui:input checked='<%=euCountry %>'
                               inlineLabel="right" labelCssClass="simple-toggle-switch" type="toggle-switch"
                               name="<%=CountryCustomSettingsConstants.EU_COUNTRY_EXPANDO_KEY%>"/>

                    <aui:button-row>
                        <aui:button cssClass="btn-lg" type="submit"/>
                    </aui:button-row>
                </aui:fieldset>
            </div>
        </div>
    </div>
</aui:form>

The form is defined with only one field for user. Then on submit we call custom MVC action command

MVC Action Command

Once the form is submitted we want to call action url /commerce_country/edit_country_custom_settings. This MVC Action Command of course does not exist. We need to define one

@Component(immediate = true,
        property = {
                "javax.portlet.name=" + CommercePortletKeys.COMMERCE_COUNTRY,
                "mvc.command.name=/commerce_country/edit_country_custom_settings"
        },
        service = MVCActionCommand.class
)
public class EditCountryCustomSettingsMVCActionCommand extends BaseMVCActionCommand {
    @Reference
    private CountryService countryService;

    @Override
    protected void doProcessAction(
            ActionRequest actionRequest, ActionResponse actionResponse) {
        long countryId = ParamUtil.getLong(actionRequest, CountryCustomSettingsConstants.PARAM_COUNTRY_ID);
        boolean euCountry = ParamUtil.getBoolean(actionRequest, CountryCustomSettingsConstants.EU_COUNTRY_EXPANDO_KEY);
        Country country = countryService.fetchCountry(countryId);
        country.getExpandoBridge().setAttribute(CountryCustomSettingsConstants.EU_COUNTRY_EXPANDO_KEY, euCountry);
    }
}

We create a class extending BaseMVCActionCommand, then we define an action in which we set the value from the form as expando of Country object.

Results

If we deploy the module and open the country details again we will see new menu entry called "Custom Settings":

Custom Settings in Navigation
Custom Settings in Navigation

and the form:

Custom settings form
Custom settings form

Note please note that I didn't mention all the code, especially the language module which provides the translation. For example can be found on GitHub in my Liferay Blog Samples repository

Summary

I hope you liked it and I encourage you to use this approach whenever you can instead of standard OSGi fragments. Sadly this approach is not always replacement, and it can only help us if we need to add new fields. It won't help in replacing existing ones and cannot be used to change things like order of fields, custom validations etc. For these you will still need the old approach.

Copyright: Rafał Pydyniak